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 "crypto/rand"
5 "crypto/sha256"
6 "crypto/subtle"
7 "encoding/base64"
8 "encoding/hex"
9 "errors"
10 "time"
11
12 "gorm.io/gorm"
13)
14
15// linkTokenBytes is the token's entropy: 32 bytes from crypto/rand.
16//
17// "High-entropy" is spec §8.3's word and this is what it has to mean in
18// practice — 256 bits, so the token cannot be guessed, enumerated, or
19// found by walking a keyspace. It is also why the stored hash can be a
20// fast one: see ShareLink's doc comment.
21const linkTokenBytes = 32
22
23// linkRoles are the only roles a link may carry.
24//
25// Never editor (ruling 12). Every version row carries an actor, and an
26// anonymous editor would write unattributed rows into version history —
27// which is not a policy preference but a thing the history model cannot
28// represent.
29var linkRoles = []SheetRole{SheetViewer, SheetCommenter}
30
31func linkRoleValid(r SheetRole) bool {
32 for _, ok := range linkRoles {
33 if r == ok {
34 return true
35 }
36 }
37 return false
38}
39
40// newLinkToken mints a token and returns it with its hash. The token is
41// returned exactly once, to be shown exactly once.
42func newLinkToken() (token, hash string, err error) {
43 buf := make([]byte, linkTokenBytes)
44 if _, err := rand.Read(buf); err != nil {
45 return "", "", err
46 }
47 token = base64.RawURLEncoding.EncodeToString(buf)
48 return token, hashLinkToken(token), nil
49}
50
51// hashLinkToken is what gets stored and what a lookup compares.
52func hashLinkToken(token string) string {
53 sum := sha256.Sum256([]byte(token))
54 return hex.EncodeToString(sum[:])
55}
56
57// errBadLinkRole is the one createLink failure the person asking can do
58// anything about.
59//
60// It is a sentinel rather than an anonymous error because the handler
61// has to tell it apart from the rest. Every other way this function
62// fails — the token generator, the insert, no database at all — is ours
63// and not theirs, and reporting those as a rejected role choice tells
64// somebody to fix the one thing that was not broken.
65var errBadLinkRole = errors.New("sheets: a link can only view or comment, never edit")
66
67// createLink mints a link for a sheet and returns the token, which the
68// caller must show once and then forget.
69func (a *App) createLink(sheetID uint64, role SheetRole, by Member) (string, error) {
70 if a.db == nil {
71 return "", errNoDB
72 }
73 if !linkRoleValid(role) {
74 // Named rather than silently downgraded: somebody asking for an
75 // editor link has a expectation this refuses, and refusing
76 // quietly would leave them thinking they made one.
77 return "", errBadLinkRole
78 }
79 token, hash, err := newLinkToken()
80 if err != nil {
81 return "", err
82 }
83 if err := a.db.Create(&ShareLink{
84 SheetID: sheetID, TokenHash: hash, Role: string(role), CreatedBy: by.ID,
85 }).Error; err != nil {
86 return "", err
87 }
88 return token, nil
89}
90
91// linksOn lists a sheet's links for the share screen. The tokens are
92// not among them — they cannot be, which is the point.
93func (a *App) linksOn(sheetID uint64) ([]ShareLink, error) {
94 if a.db == nil {
95 return nil, errNoDB
96 }
97 var rows []ShareLink
98 err := a.db.Where("sheet_id = ? AND revoked_at IS NULL", sheetID).
99 Order("id ASC").Find(&rows).Error
100 return rows, err
101}
102
103// revokeLink turns a link off. The row survives so that somebody
104// holding a stale URL can be told the link was turned off rather than
105// that it never existed.
106func (a *App) revokeLink(sheetID, linkID uint64) error {
107 if a.db == nil {
108 return errNoDB
109 }
110 now := time.Now().UTC()
111 return a.db.Model(&ShareLink{}).
112 Where("id = ? AND sheet_id = ? AND revoked_at IS NULL", linkID, sheetID).
113 Update("revoked_at", now).Error
114}
115
116// linkFor resolves a token to its live link.
117//
118// The lookup is by hash, so the token never has to be compared against
119// anything stored. The constant-time comparison afterwards is belt and
120// braces on an already-indexed exact match: it costs nothing and
121// removes any argument about the index leaking timing.
122func (a *App) linkFor(token string) (ShareLink, bool) {
123 if a.db == nil || token == "" {
124 return ShareLink{}, false
125 }
126 hash := hashLinkToken(token)
127 var link ShareLink
128 err := a.db.Where("token_hash = ?", hash).First(&link).Error
129 if err != nil {
130 if !errors.Is(err, gorm.ErrRecordNotFound) {
131 return ShareLink{}, false
132 }
133 return ShareLink{}, false
134 }
135 if subtle.ConstantTimeCompare([]byte(link.TokenHash), []byte(hash)) != 1 {
136 return ShareLink{}, false
137 }
138 if link.RevokedAt != nil {
139 return ShareLink{}, false
140 }
141 if !a.sheetExists(link.SheetID) {
142 return ShareLink{}, false
143 }
144 return link, true
145}
146