main pico / pkg / db / stub / posts.go
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}