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 "errors"
5 "net/http"
6 "strings"
7
8 "gorm.io/gorm"
9 "gorm.io/gorm/clause"
10)
11
12// errNoDB is what every service method answers when it is called on an
13// App built without storage. Tests that render pages do that
14// deliberately; production never can, because main refuses to boot
15// without a database.
16var errNoDB = errors.New("sheets: no database")
17
18// memberFor returns the Member row for an address, creating it the
19// first time that address is seen.
20//
21// First sight is the right moment because admission has already
22// happened: auth.Config.Authorize refused every address not on the
23// allowlist before a session could exist, so anything reaching here
24// belongs here. Minting rows at boot from SHEETS_OWNERS instead would
25// create members for people who never signed in, and would go stale the
26// moment the allowlist changed.
27//
28// The write is an upsert-then-read rather than a read-then-create: two
29// tabs signing in at the same moment would otherwise race, and the loser
30// would get a unique-constraint error instead of a member.
31func (a *App) memberFor(address string) (Member, error) {
32 addr := strings.ToLower(strings.TrimSpace(address))
33 if addr == "" {
34 // An empty address would mint one shared member that every
35 // unauthenticated path then wrote edits as — the uid-0 trap
36 // wearing a different hat.
37 return Member{}, errors.New("sheets: refusing to mint a member for an empty address")
38 }
39 if a.db == nil {
40 return Member{}, errNoDB
41 }
42 // SHEETS_OWNERS is the team; anybody else who reaches here arrived
43 // through a share and is a guest. Guest is the safe default: the
44 // wrong direction is unrecoverable in the way that matters, since a
45 // guest wrongly made a member sees every sheet on the instance.
46 role := TeamGuest
47 if a.admit != nil && a.admit(addr) {
48 role = TeamOwner
49 }
50
51 m := Member{Address: addr, TeamRole: string(role)}
52 if err := a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&m).Error; err != nil {
53 return Member{}, err
54 }
55 if m.ID != 0 {
56 return m, nil
57 }
58 // The insert conflicted, so the row is already there: read it.
59 var existing Member
60 if err := a.db.Where("address = ?", addr).First(&existing).Error; err != nil {
61 if errors.Is(err, gorm.ErrRecordNotFound) {
62 return Member{}, errors.New("sheets: member neither created nor found")
63 }
64 return Member{}, err
65 }
66 // Promote a row that predates the allowlist, or predates team roles
67 // existing at all. Never demote: a role somebody was given by hand
68 // is not this function's to take away.
69 if role == TeamOwner && TeamRole(existing.TeamRole) != TeamOwner {
70 if err := a.db.Model(&existing).Update("team_role", string(TeamOwner)).Error; err != nil {
71 return Member{}, err
72 }
73 existing.TeamRole = string(TeamOwner)
74 }
75 return existing, nil
76}
77
78// actor resolves the signed-in viewer to a Member.
79//
80// No session is not an error — it is "no actor" — so callers gate on ok
81// rather than on err. Every route that calls this has already been
82// through requireSession, so a false here in production means the
83// session went away between the middleware and the handler.
84func (a *App) actor(r *http.Request) (Member, bool) {
85 if a.home != nil {
86 s, ok := a.homeSessions.From(r)
87 if ok && s.Method == "home-assertion" {
88 p, err := a.home.ResolveSession(r)
89 if err != nil {
90 return Member{}, false
91 }
92 return a.memberByID(uint64(p.ID))
93 }
94 }
95 address, ok := a.identity(r)
96 if !ok {
97 return Member{}, false
98 }
99 m, err := a.memberFor(address)
100 if err != nil {
101 return Member{}, false
102 }
103 return m, true
104}
105
106// memberByID reads one member row.
107//
108// It exists for the share screen's revoke path, which has an id from a
109// form and needs the person's TEAM standing — the field that decides
110// whether removing their grant actually removed anything. Reading the
111// row is the only way to know: team standing is not in the grants
112// table, which is exactly why revoke could not see it.
113func (a *App) memberByID(id uint64) (Member, bool) {
114 if a.db == nil || id == 0 {
115 return Member{}, false
116 }
117 var m Member
118 if err := a.db.Where("id = ?", id).First(&m).Error; err != nil {
119 return Member{}, false
120 }
121 return m, true
122}
123