Antonio Mika
·
2026-10-10
1package stub
2
3import (
4 "database/sql"
5 "fmt"
6 "log/slog"
7 "slices"
8 "sync"
9 "time"
10
11 "github.com/picosh/pico/pkg/db"
12)
13
14// PostsDB keeps one user's posts in memory, with the unique filename and
15// slug constraints of the posts table.
16type PostsDB struct {
17 *StubDB
18 User *db.User
19 Pubkey string
20
21 mu sync.Mutex
22 posts []*db.Post
23 aliases map[string][]string
24 n int
25}
26
27func NewPostsDB(logger *slog.Logger, user *db.User) *PostsDB {
28 return &PostsDB{StubDB: NewStubDB(logger), User: user}
29}
30
31func (m *PostsDB) FindUserByPubkey(key string) (*db.User, error) {
32 if key != m.Pubkey {
33 return nil, sql.ErrNoRows
34 }
35 return m.User, nil
36}
37
38func (m *PostsDB) ReplaceTagsByPost(tags []string, id string) error {
39 m.mu.Lock()
40 defer m.mu.Unlock()
41 if i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == id }); i >= 0 {
42 m.posts[i].Tags = slices.Clone(tags)
43 }
44 return nil
45}
46
47func (m *PostsDB) ReplaceAliasesByPost(aliases []string, id string) error {
48 m.mu.Lock()
49 defer m.mu.Unlock()
50 m.setAliases(id, aliases)
51 return nil
52}
53
54// Aliases returns the aliases of the post with id.
55func (m *PostsDB) Aliases(id string) []string {
56 m.mu.Lock()
57 defer m.mu.Unlock()
58 return slices.Clone(m.aliases[id])
59}
60
61// setAliases stores a post's aliases. The caller holds m.mu.
62func (m *PostsDB) setAliases(id string, aliases []string) {
63 if m.aliases == nil {
64 m.aliases = map[string][]string{}
65 }
66 m.aliases[id] = slices.Clone(aliases)
67}
68
69func (m *PostsDB) FindUser(string) (*db.User, error) { return m.User, nil }
70func (m *PostsDB) FindUserByName(string) (*db.User, error) { return m.User, nil }
71func (m *PostsDB) InsertAccessLog(*db.AccessLog) error { return nil }
72
73func (m *PostsDB) FindFeature(userID, name string) (*db.FeatureFlag, error) {
74 if name != "plus" {
75 return nil, sql.ErrNoRows
76 }
77 expires := time.Now().Add(time.Hour)
78 return &db.FeatureFlag{UserID: userID, Name: name, ExpiresAt: &expires}, nil
79}
80
81func (m *PostsDB) find(match func(*db.Post) bool) (*db.Post, error) {
82 m.mu.Lock()
83 defer m.mu.Unlock()
84 i := slices.IndexFunc(m.posts, match)
85 if i < 0 {
86 return nil, sql.ErrNoRows
87 }
88 p := *m.posts[i]
89 return &p, nil
90}
91
92func (m *PostsDB) FindPost(id string) (*db.Post, error) {
93 return m.find(func(p *db.Post) bool { return p.ID == id })
94}
95
96func (m *PostsDB) FindPostWithFilename(filename, userID, space string) (*db.Post, error) {
97 return m.find(func(p *db.Post) bool { return p.UserID == userID && p.Space == space && p.Filename == filename })
98}
99
100func (m *PostsDB) FindPostWithSlug(slug, userID, space string) (*db.Post, error) {
101 return m.find(func(p *db.Post) bool { return p.UserID == userID && p.Space == space && p.Slug == slug })
102}
103
104func (m *PostsDB) FindAllPostsByUser(userID, space string) ([]*db.Post, error) {
105 m.mu.Lock()
106 defer m.mu.Unlock()
107 var out []*db.Post
108 for _, p := range m.posts {
109 if p.UserID == userID && p.Space == space {
110 c := *p
111 out = append(out, &c)
112 }
113 }
114 return out, nil
115}
116
117// conflict reports a post other than the ones in skip that p's filename or
118// slug would collide with. The caller holds m.mu.
119func (m *PostsDB) conflict(p *db.Post, skip ...string) error {
120 for _, o := range m.posts {
121 if slices.Contains(skip, o.ID) || o.UserID != p.UserID || o.Space != p.Space {
122 continue
123 }
124 if o.Filename == p.Filename || o.Slug == p.Slug {
125 return fmt.Errorf("duplicate key value violates unique constraint: %s", o.Filename)
126 }
127 }
128 return nil
129}
130
131func (m *PostsDB) InsertPost(post *db.Post) (*db.Post, error) {
132 m.mu.Lock()
133 defer m.mu.Unlock()
134 if err := m.conflict(post); err != nil {
135 return nil, err
136 }
137 m.n++
138 p := *post
139 p.ID = fmt.Sprintf("post-%d", m.n)
140 now := time.Now()
141 p.CreatedAt = &now
142 m.posts = append(m.posts, &p)
143 c := p
144 return &c, nil
145}
146
147func (m *PostsDB) update(post *db.Post, filename bool, skip ...string) (*db.Post, error) {
148 i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == post.ID })
149 if i < 0 {
150 return nil, sql.ErrNoRows
151 }
152 next := *m.posts[i]
153 if filename {
154 next.Filename, next.MimeType = post.Filename, post.MimeType
155 }
156 next.Slug, next.Title, next.Text, next.Description = post.Slug, post.Title, post.Text, post.Description
157 next.UpdatedAt, next.PublishAt, next.FileSize, next.Shasum = post.UpdatedAt, post.PublishAt, post.FileSize, post.Shasum
158 next.Data, next.Hidden, next.ExpiresAt = post.Data, post.Hidden, post.ExpiresAt
159 if err := m.conflict(&next, append(skip, post.ID)...); err != nil {
160 return nil, err
161 }
162 m.posts[i] = &next
163 c := next
164 return &c, nil
165}
166
167func (m *PostsDB) UpdatePost(post *db.Post) (*db.Post, error) {
168 m.mu.Lock()
169 defer m.mu.Unlock()
170 return m.update(post, false)
171}
172
173func (m *PostsDB) RenamePost(cur, next, replaced *db.Post, tags, aliases []string) (*db.Post, error) {
174 m.mu.Lock()
175 defer m.mu.Unlock()
176 unchanged := func(read *db.Post, all bool) bool {
177 i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == read.ID })
178 if i < 0 {
179 return false
180 }
181 p := m.posts[i]
182 same := p.Filename == read.Filename && p.Text == read.Text && equalTime(p.UpdatedAt, read.UpdatedAt)
183 if all {
184 same = same && p.Slug == read.Slug && p.Title == read.Title && p.Description == read.Description &&
185 equalTime(p.PublishAt, read.PublishAt) && p.Hidden == read.Hidden && equalTime(p.ExpiresAt, read.ExpiresAt)
186 }
187 return same
188 }
189 if !unchanged(cur, true) || (replaced != nil && !unchanged(replaced, false)) {
190 return nil, db.ErrPostChanged
191 }
192 // Only what a new name can change is written.
193 update := *m.posts[slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == cur.ID })]
194 update.Filename, update.Slug, update.Title, update.Description = next.Filename, next.Slug, next.Title, next.Description
195 update.PublishAt, update.Hidden, update.ExpiresAt, update.MimeType = next.PublishAt, next.Hidden, next.ExpiresAt, next.MimeType
196 var skip []string
197 if replaced != nil {
198 skip = append(skip, replaced.ID)
199 }
200 p, err := m.update(&update, true, skip...)
201 if err != nil {
202 return nil, err
203 }
204 if replaced != nil {
205 m.posts = slices.DeleteFunc(m.posts, func(p *db.Post) bool { return p.ID == replaced.ID })
206 }
207 i := slices.IndexFunc(m.posts, func(p *db.Post) bool { return p.ID == cur.ID })
208 m.posts[i].Tags = slices.Clone(tags)
209 m.setAliases(cur.ID, aliases)
210 p.Tags = slices.Clone(tags)
211 return p, nil
212}
213
214func equalTime(a, b *time.Time) bool {
215 return (a == nil && b == nil) || (a != nil && b != nil && a.Equal(*b))
216}
217
218func (m *PostsDB) RemovePosts(ids []string) error {
219 m.mu.Lock()
220 defer m.mu.Unlock()
221 m.posts = slices.DeleteFunc(m.posts, func(p *db.Post) bool { return slices.Contains(ids, p.ID) })
222 return nil
223}