oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "time"
10
11 "gorm.io/gorm"
12)
13
14// Link is one document's read-only public capability: whoever holds the
15// token may read the document, signed in or not.
16//
17// The third rung of the suite doctrine's ruling 15, and the reason it has
18// no Role field is the ruling's other half — every write has a named
19// actor, and a link holds no name. See 0006_links.sql.
20type Link struct {
21 ID int64 `gorm:"column:id;primaryKey"`
22 DocID int64 `gorm:"column:doc_id;uniqueIndex"`
23 // TokenHash, never the token (suite doctrine ruling 16). There is no
24 // field on this struct that can yield a working URL, which is the
25 // point: nothing that reads a Link out of the database — a query, a
26 // log line, a debugger — is holding a capability.
27 TokenHash string `gorm:"column:token_hash;uniqueIndex"`
28 CreatedAt time.Time `gorm:"column:created_at"`
29}
30
31// TableName pins the table rather than leaving it to GORM's pluraliser,
32// the same reason Grant does.
33func (Link) TableName() string { return "links" }
34
35// tokenBytes is 128 bits of entropy, which base64url renders as 22
36// characters. The token is the entire authorization for a published
37// document, so this is the one number in this file worth arguing about:
38// 128 bits is the floor at which guessing stops being a strategy, and a
39// URL a person pastes into a message is not improved by being longer.
40const tokenBytes = 16
41
42// newToken mints a capability. crypto/rand, never math/rand, and never
43// anything derived from the document — a token computed from doc_id, sqid,
44// title or a clock is guessable by whoever reads this file.
45//
46// rand.Read is documented never to return a short read without an error,
47// so an error here means the system has no entropy and the honest response
48// is to fail the request rather than publish under a weak token.
49func newToken() (string, error) {
50 buf := make([]byte, tokenBytes)
51 if _, err := rand.Read(buf); err != nil {
52 return "", err
53 }
54 return base64.RawURLEncoding.EncodeToString(buf), nil
55}
56
57// hashToken is the one-way form stored in the database.
58//
59// SHA-256, unsalted, and both halves of that are deliberate. A salt
60// defends against precomputation over a small input space; there is no
61// small input space here, because the token is 128 bits from crypto/rand
62// and is its own salt. A password hash (bcrypt, argon2) would buy the same
63// nothing while making every load of a published page do key-stretching
64// work. The property that matters — the stored form does not yield the
65// token — a fast hash gives just as completely.
66//
67// Hex rather than base64 for the stored form: it is fixed-width, it is
68// what a person comparing rows by eye expects of a SHA-256, and the column
69// is never parsed by anything but this function.
70func hashToken(token string) string {
71 sum := sha256.Sum256([]byte(token))
72 return hex.EncodeToString(sum[:])
73}
74
75// Publish turns a document's public link on and returns it, minting a
76// fresh token every time.
77//
78// Fresh every time, deliberately: with Unpublish deleting the row, that
79// makes "off" a revocation rather than a pause. Somebody who kept a URL
80// from an earlier audience does not get back in when the owner publishes
81// again for a different one.
82//
83// Owner-only through Get, so a writer grantee is answered exactly as a
84// stranger is — no grant carries re-sharing, and publishing to everyone is
85// the largest re-share there is.
86func (s *Store) Publish(ctx context.Context, owner int64, sqid string) (string, error) {
87 doc, err := s.Get(ctx, owner, sqid)
88 if err != nil {
89 return "", err
90 }
91 token, err := newToken()
92 if err != nil {
93 return "", err
94 }
95
96 link := Link{DocID: doc.ID, TokenHash: hashToken(token), CreatedAt: time.Now().UTC()}
97 // Delete-then-insert rather than an upsert on doc_id: the old token
98 // must stop working, and a row whose token changed and a row that was
99 // replaced are the same outcome only if nothing else ever holds a
100 // reference to the row. Nothing does, and this way the intent reads
101 // off the code.
102 err = s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error {
103 if err := tx.Where("doc_id = ?", doc.ID).Delete(&Link{}).Error; err != nil {
104 return err
105 }
106 return tx.Create(&link).Error
107 })
108 if err != nil {
109 return "", err
110 }
111 // The plaintext token is returned here and never again: this is the
112 // only moment it exists outside the caller's hand (ruling 16).
113 return token, nil
114}
115
116// Unpublish turns it off. Like Revoke, unpublishing something that is not
117// published succeeds: the owner asked for an outcome — that this document
118// is not readable by strangers — and that outcome holds either way.
119func (s *Store) Unpublish(ctx context.Context, owner int64, sqid string) error {
120 doc, err := s.Get(ctx, owner, sqid)
121 if err != nil {
122 return err
123 }
124 return s.ownerMutation(ctx, owner, doc.ID, func(tx *gorm.DB) error { return tx.Where("doc_id = ?", doc.ID).Delete(&Link{}).Error })
125}
126
127// IsPublished reports whether a document currently has a public link.
128//
129// It answers a bool and not a link, and that is ruling 16 showing up in a
130// signature: there is no way to ask this store for a published document's
131// URL, because the store does not have it. The share page can say "this is
132// published" and offer to replace or stop it; it cannot show the URL
133// again, and no future caller can be tempted to make it.
134//
135// Owner-scoped through Get: whether a document is published is part of the
136// share page, which is the owner's alone.
137func (s *Store) IsPublished(ctx context.Context, owner int64, sqid string) (bool, error) {
138 doc, err := s.Get(ctx, owner, sqid)
139 if err != nil {
140 return false, err
141 }
142 var count int64
143 if err := s.db.WithContext(ctx).Model(&Link{}).
144 Where("doc_id = ?", doc.ID).Count(&count).Error; err != nil {
145 return false, err
146 }
147 return count > 0, nil
148}
149
150// ByToken resolves a public link to its document, for the one route in
151// this app that no session guards.
152//
153// An unknown token and a token whose document has been deleted answer
154// identically — gorm.ErrRecordNotFound, which the handler turns into the
155// same 404 a nonexistent URL gets. That is §4.2's rule reaching the public
156// route: without it, the difference between the two answers is an oracle
157// for which documents are published.
158//
159// The empty token is rejected before the query, so a request to a route
160// that somehow lost its path value cannot match a row with an empty
161// column.
162func (s *Store) ByToken(ctx context.Context, token string) (*Document, error) {
163 if token == "" {
164 return nil, gorm.ErrRecordNotFound
165 }
166 var doc Document
167 err := s.db.WithContext(ctx).
168 Joins("JOIN links ON links.doc_id = documents.id").
169 Where("links.token_hash = ? AND documents.deleted_at IS NULL AND documents.owner_id > 0",
170 hashToken(token)).
171 First(&doc).Error
172 if err != nil {
173 return nil, err
174 }
175 return &doc, nil
176}
177