oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "amadan.net/oficina/estilo.git/brand"
5 "amadan.net/oficina/estilo.git/chrome"
6 "amadan.net/oficina/estilo.git/sky"
7 "context"
8 "database/sql"
9 "errors"
10 "net/http"
11 "net/url"
12 "strings"
13 "time"
14
15 "amadan.net/oficina/home/connect"
16 "amadan.net/oficina/home/connect/linkstore"
17 "amadan.net/rastrillo/rastrillo/csrf"
18 "amadan.net/rastrillo/rastrillo/sessions"
19)
20
21var errHome = errors.New("sheets: Home connection unavailable")
22
23// ConfigureHome runs after local auth is attached and before serving requests.
24func (a *App) ConfigureHome(raw *connect.Credentials) error {
25 settings, err := connect.DecodeCredentials(raw, a.origin)
26 if err != nil {
27 return errHome
28 }
29 if settings == nil {
30 return nil
31 }
32 if a.db == nil || a.auth == nil {
33 return errHome
34 }
35 d, err := a.db.DB()
36 if err != nil {
37 return errHome
38 }
39 sess, err := sessions.New(sessions.Config{DB: d, Origin: a.origin, SigninPath: "/signin"})
40 if err != nil || sess.CookieName() != a.auth.SessionCookie() {
41 return errHome
42 }
43 read := func(r *http.Request) (*connect.Session, error) {
44 s, ok := sess.From(r)
45 if !ok {
46 return nil, nil
47 }
48 return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil
49 }
50 render := func(w http.ResponseWriter, r *http.Request, state, label string, status int) {
51 w.Header().Set("Cache-Control", "no-store")
52 if state == "signedout" {
53 // The suite's signed-out page (spec §9.2), not connect's
54 // "Sign in with Home" one.
55 a.renderSignedOut(w, r, status)
56 return
57 }
58 p := connect.Page(state, label, func(k string) string { return homeCopy[k] })
59 a.render(w, r, "home_link", titled(p.Title), status, p)
60 }
61 bridge, err := connect.New(connect.Config{DB: d, Origin: a.origin, Issuer: settings.Issuer, ConsumerID: settings.ConsumerID, Namespace: "oficina/sheets", Keys: settings.Keys, DEK: settings.DEK, SessionCookie: sess.CookieName(), ReadSession: read,
62 MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error {
63 return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime})
64 },
65 LookupPerson: a.homePerson,
66 LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) {
67 var id int64
68 address := strings.ToLower(strings.TrimSpace(s.Subject))
69 if err := d.QueryRowContext(ctx, "SELECT id FROM members WHERE address=?", address).Scan(&id); err != nil {
70 return connect.Person{}, err
71 }
72 label, err := a.homePerson(ctx, d, id)
73 return connect.Person{ID: id, Label: label}, err
74 }, Render: render})
75 if err != nil {
76 return errHome
77 }
78 routes, err := connect.NewRoutes(bridge, connect.RoutesConfig{LocalLandingPath: "/sheets", Namespace: "oficina/sheets", Origin: a.origin, Issuer: settings.Issuer, SessionCookie: sess.CookieName(), DEK: settings.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.handleSignin, Protect: csrf.Protect(a.origin), Render: render, FormPolicy: connect.FormPolicy(settings.Issuer)})
79 if err != nil {
80 return errHome
81 }
82 a.home, a.homeRoutes, a.homeSessions = bridge, routes, sess
83 a.directory = settings.Directory
84 return nil
85}
86
87// The same current admission rule as Admits, using the link transaction's q.
88func (a *App) homePerson(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
89 var address string
90 if id <= 0 {
91 return "", sql.ErrNoRows
92 }
93 if err := q.QueryRowContext(ctx, "SELECT address FROM members WHERE id=?", id).Scan(&address); err != nil {
94 return "", err
95 }
96 if address == "" {
97 return "", sql.ErrNoRows
98 }
99 if a.admit != nil && a.admit(address) {
100 return address, nil
101 }
102 var count int
103 err := q.QueryRowContext(ctx, `SELECT COUNT(*) FROM grants JOIN sheets ON sheets.id=grants.sheet_id WHERE grants.member_id=? AND sheets.deleted_at IS NULL AND sheets.filling_since IS NULL`, id).Scan(&count)
104 if err != nil {
105 return "", err
106 }
107 if count == 0 {
108 return "", sql.ErrNoRows
109 }
110 return address, nil
111}
112
113func (a *App) homeRequire(next http.Handler) http.Handler {
114 return a.homeSessions.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115 session, ok := a.homeSessions.From(r)
116 if ok && session.Method == connect.HomeMethod {
117 if _, err := a.home.ResolveSession(r); errors.Is(err, linkstore.ErrStorage) {
118 w.Header().Set("Cache-Control", "no-store")
119 http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable)
120 return
121 }
122 }
123 address, admitted := a.identity(r)
124 if !ok || !admitted || !a.Admits(address) {
125 if ok {
126 a.homeSessions.SignOut(w, r)
127 }
128 w.Header().Set("Cache-Control", "no-store")
129 if r.Method == http.MethodGet || r.Method == http.MethodHead {
130 http.Redirect(w, r, "/signin?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
131 } else {
132 http.Error(w, "Sign in first.", http.StatusForbidden)
133 }
134 return
135 }
136 // Jobs use an already resolved local address so local and Home sessions have
137 // the same owner. The bridge always rereads the actual cookie-backed session.
138 session.Subject = address
139 next.ServeHTTP(w, sessions.WithSession(r, session))
140 }))
141}
142
143func (a *App) homeLocalComplete(w http.ResponseWriter, r *http.Request) {
144 s, ok := a.homeSessions.From(r)
145 if !ok {
146 a.homeRoutes.LocalComplete(w, r)
147 return
148 }
149 _, fresh := connect.FreshLocal(connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, time.Now())
150 if !fresh || !a.Admits(s.Subject) {
151 http.Error(w, "Sign in first.", http.StatusForbidden)
152 return
153 }
154 // Only an independently verified local sign-in may create its local member.
155 // Home assertions and lookup hooks never pass through this completion handler.
156 if _, err := a.memberFor(s.Subject); err != nil {
157 http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable)
158 return
159 }
160 a.homeRoutes.LocalComplete(w, r)
161}
162
163// renderHomeOAuth is the page between the door and Keymail, in both
164// modes (handleBegin), and it is the same card: the refresh stays
165// Home's (head), the body is the door's notice.
166func (a *App) renderHomeOAuth(w http.ResponseWriter, r *http.Request, destination string) {
167 p := connect.OAuthPage(destination)
168 a.render(w, r, "home_oauth", frame{parts: []string{p.Title}, signinPage: true}, http.StatusOK, homeOAuthView{
169 Page: p,
170 Door: chrome.SignInNotice{Icon: brand.Path("sheets"), App: "Sheets", Sky: sky.For(r.Host),
171 Heading: p.Title, Body: "Taking you to Keymail to finish signing in.",
172 Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel}},
173 })
174}
175
176func (a *App) rejectDisabledHome(next http.Handler) http.Handler {
177 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178 if s, ok := sessions.Current(r); ok && s.Method == connect.HomeMethod {
179 w.Header().Set("Cache-Control", "no-store")
180 http.Error(w, "Sign in first.", http.StatusForbidden)
181 return
182 }
183 next.ServeHTTP(w, r)
184 })
185}
186
187// homeOAuthView is the Keymail continuation page's data: Home's page (for
188// its meta refresh) and the door card around the way on.
189type homeOAuthView struct {
190 Page connect.OAuthData
191 Door chrome.SignInNotice
192}
193