oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bytes"
5 "context"
6 "database/sql"
7 "errors"
8 "net/http"
9
10 "amadan.net/oficina/home/connect"
11 "amadan.net/oficina/home/connect/linkstore"
12 "amadan.net/rastrillo/rastrillo/csrf"
13 "amadan.net/rastrillo/rastrillo/sessions"
14)
15
16type homeConnection struct {
17 bridge *connect.Bridge
18 routes *connect.Routes
19 sessions *sessions.Sessions
20 admitted allowlist
21}
22
23func (a *App) newHome(settings *connect.DecodedCredentials, list allowlist) (*homeConnection, error) {
24 writer := a.db.Writer()
25 sess, err := sessions.New(sessions.Config{DB: writer, Origin: a.cfg.Origin, Logger: a.logger})
26 if err != nil {
27 return nil, err
28 }
29 read := func(r *http.Request) (*connect.Session, error) {
30 s, ok := sess.From(r)
31 if !ok {
32 return nil, nil
33 }
34 return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil
35 }
36 lookup := func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
37 var address string
38 if err := q.QueryRowContext(ctx, `SELECT address FROM people WHERE id=?`, id).Scan(&address); err != nil {
39 return "", err
40 }
41 if !list.allows(address) {
42 return "", sql.ErrNoRows
43 }
44 return address, nil
45 }
46 local := func(ctx context.Context, s connect.Session) (connect.Person, error) {
47 if s.Method != "magiclink" && s.Method != "keymail" {
48 return connect.Person{}, sql.ErrNoRows
49 }
50 var p connect.Person
51 if err := writer.QueryRowContext(ctx, `SELECT id,address FROM people WHERE address=?`, normalizeAddress(s.Subject)).Scan(&p.ID, &p.Label); err != nil {
52 return p, err
53 }
54 if !list.allows(p.Label) {
55 return connect.Person{}, sql.ErrNoRows
56 }
57 return p, nil
58 }
59 page := func(w http.ResponseWriter, r *http.Request, state, label string, status int) {
60 data := connect.Page(state, label)
61 // Where sign-out lands is the Oficina door, in the door's words.
62 tree, title, view := pages["home"], data.Title, any(data)
63 if state == "signedout" {
64 notice := signedOutDoor(r, data.Action)
65 tree, title, view = pages["signin"], notice.Page(), signinData{SignedOut: &notice}
66 }
67 var buf bytes.Buffer
68 if err := tree.ExecuteTemplate(&buf, "layout", a.h.page(r, title, view)); err != nil {
69 a.h.errorPage(w, r, http.StatusInternalServerError, "")
70 return
71 }
72 w.Header().Set("Cache-Control", "no-store")
73 w.Header().Set("Content-Type", "text/html; charset=utf-8")
74 w.WriteHeader(status)
75 _, _ = buf.WriteTo(w)
76 }
77 b, err := connect.New(connect.Config{DB: writer, Origin: a.cfg.Origin, Issuer: settings.Issuer, ConsumerID: settings.ConsumerID, Keys: settings.Keys, DEK: settings.DEK, Namespace: "oficina/calendar", SessionCookie: sess.CookieName(), ReadSession: read, LookupLocalSession: local, LookupPerson: lookup, Render: page, MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error {
78 return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime})
79 }})
80 if err != nil {
81 return nil, err
82 }
83 routes, err := connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/calendar", Origin: a.cfg.Origin, Issuer: settings.Issuer, SessionCookie: sess.CookieName(), DEK: settings.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.h.signin, Protect: csrf.Protect(a.cfg.Origin), Render: page})
84 if err != nil {
85 return nil, err
86 }
87 return &homeConnection{bridge: b, routes: routes, sessions: sess, admitted: list}, nil
88}
89
90func (a *App) homePerson(r *http.Request) (Person, error) {
91 p, err := a.home.bridge.ResolveSession(r)
92 if err != nil {
93 return Person{}, err
94 }
95 var person Person
96 if err = a.db.G.WithContext(r.Context()).First(&person, p.ID).Error; err != nil {
97 return Person{}, err
98 }
99 if person.ID != p.ID || person.Address != p.Label {
100 return Person{}, errors.New("calendar: identity unavailable")
101 }
102 return person, nil
103}
104
105// homeSignin is GET /signin with Home on. connect's Signin shortcuts a
106// signed-in person straight to where they were headed; but a session whose
107// person is no longer admitted (removed from the team, or a Home link to a
108// deleted person) is not signed in for Calendar, and the shortcut would end
109// on requirePerson's 404 with nowhere to go. So /signin first asks the same
110// question requirePerson does. Not admitted: the session ends here and
111// /signin is asked again without it, which starts a fresh exchange (as
112// sheets' homeRequire and docs' admission check do). A storage failure is
113// a 503, never a sign-out.
114func (a *App) homeSignin(w http.ResponseWriter, r *http.Request) {
115 s, ok := a.home.sessions.From(r)
116 if ok && (s.Method == connect.HomeMethod || s.Method == "magiclink" || s.Method == "keymail") {
117 admitted := false
118 if s.Method == connect.HomeMethod {
119 _, err := a.homePerson(r)
120 if errors.Is(err, linkstore.ErrStorage) {
121 w.Header().Set("Cache-Control", "no-store")
122 a.h.errorPage(w, r, http.StatusServiceUnavailable, "")
123 return
124 }
125 admitted = err == nil
126 } else {
127 admitted = a.home.admitted.allows(normalizeAddress(s.Subject))
128 }
129 if !admitted {
130 a.home.sessions.SignOut(w, r)
131 w.Header().Set("Cache-Control", "no-store")
132 http.Redirect(w, r, r.URL.RequestURI(), http.StatusSeeOther)
133 return
134 }
135 }
136 a.home.routes.Signin(w, r)
137}
138