| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 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. |
| 16 | var 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. |
| 31 | func (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. |
| 84 | func (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. |
| 113 | func (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 | |