Author SHA1 Message Date
codegirl007 394c7413c4 Add post image upload interface (#12)
Adds accessible drag-and-drop and browse controls, staged attachment editing, responsive lazy-loaded thread image grids, and UI regression coverage.

Reviewed-on: #12
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 08:01:04 +00:00
codegirl007 677e63329d Add post image upload backend
CI / test (pull_request) Successful in 7m1s
2026-08-27 23:53:27 -07:00
codegirl007 1840a662d9 Add post image storage
CI / test (pull_request) Successful in 6m17s
2026-08-27 23:46:06 -07:00
35 changed files with 1528 additions and 1641 deletions
-4
View File
@@ -26,7 +26,3 @@ SECURE_COOKIE=0
# SPACES_BUCKET=your-bucket
# SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
# SPACES_CDN_BASE=https://your-bucket.nyc3.cdn.digitaloceanspaces.com
# Discord bot subscriber. Leave unset to disable.
# DISCORD_BOT_TOKEN=
# DISCORD_CHANNEL_ID=
# DISCORD_ADMIN_MAP=123456789012345678:plumber,234567890123456789:otheradmin
+2 -14
View File
@@ -17,8 +17,6 @@ import (
"plumber"
"plumber/internal/blob"
"plumber/internal/discord"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/store"
"plumber/internal/web"
@@ -36,15 +34,7 @@ func main() {
if err != nil {
log.Fatalf("mail: %v", err)
}
bus := events.New()
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus)
if err != nil {
log.Fatalf("discord: %v", err)
}
if bot != nil {
defer bot.Close()
}
handler := newHandler(db, sessions, uploader, notifier, bus)
handler := newHandler(db, sessions, uploader, notifier)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -68,15 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier, bus events.Publisher) http.Handler {
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, notifier mail.Notifier) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(),
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
Blob: uploader,
Mail: notifier,
Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
})
if err != nil {
log.Fatalf("server: %v", err)
-32
View File
@@ -1,32 +0,0 @@
-- name: GetDiscordPostLinkByPostID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE post_id = sqlc.arg(post_id);
-- name: GetDiscordPostLinkByMessageID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_message_id = sqlc.arg(discord_message_id);
-- name: GetDiscordPostLinkByThreadID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_thread_id = sqlc.arg(discord_thread_id)
AND discord_thread_id <> '';
-- name: UpsertDiscordPostLink :exec
INSERT INTO discord_post_links (
post_id, discord_message_id, discord_thread_id, created_at
)
VALUES (
sqlc.arg(post_id),
sqlc.arg(discord_message_id),
sqlc.arg(discord_thread_id),
sqlc.arg(created_at)
)
ON CONFLICT (post_id) DO UPDATE SET
discord_message_id = EXCLUDED.discord_message_id,
discord_thread_id = CASE
WHEN EXCLUDED.discord_thread_id <> '' THEN EXCLUDED.discord_thread_id
ELSE discord_post_links.discord_thread_id
END;
+2 -4
View File
@@ -7,12 +7,11 @@ require (
github.com/aws/aws-sdk-go-v2 v1.43.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.37
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
github.com/bwmarrin/discordgo v0.29.0
github.com/go-chi/chi/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/resend/resend-go/v3 v3.16.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
)
@@ -27,11 +26,10 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
github.com/aws/smithy-go v1.27.8 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/resend/resend-go/v3 v3.16.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+2 -12
View File
@@ -24,8 +24,6 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA=
github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,8 +31,6 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -49,27 +45,21 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.16.0 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE=
github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-102
View File
@@ -1,102 +0,0 @@
package discord
import (
"context"
"github.com/bwmarrin/discordgo"
)
// API is the Discord REST surface used by the outbound subscriber.
type API interface {
SendToChannel(ctx context.Context, channelID string, msg Message) (messageID string, err error)
StartThread(ctx context.Context, channelID, messageID, name string) (threadID string, err error)
SendToThread(ctx context.Context, threadID string, msg Message) (messageID string, err error)
Edit(ctx context.Context, channelID, messageID string, msg Message) error
Close() error
}
type sessionAPI struct {
session *discordgo.Session
}
func (s *sessionAPI) SendToChannel(_ context.Context, channelID string, msg Message) (string, error) {
sent, err := s.session.ChannelMessageSendComplex(channelID, toMessageSend(msg))
if err != nil {
return "", err
}
return sent.ID, nil
}
func (s *sessionAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) {
thread, err := s.session.MessageThreadStartComplex(channelID, messageID, &discordgo.ThreadStart{
Name: name,
AutoArchiveDuration: 10080,
})
if err != nil {
return "", err
}
return thread.ID, nil
}
func (s *sessionAPI) SendToThread(ctx context.Context, threadID string, msg Message) (string, error) {
return s.SendToChannel(ctx, threadID, msg)
}
func (s *sessionAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error {
embeds := toEmbeds(msg)
_, err := s.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
ID: messageID,
Channel: channelID,
Embeds: &embeds,
})
return err
}
func (s *sessionAPI) Close() error {
if s == nil || s.session == nil {
return nil
}
return s.session.Close()
}
func toMessageSend(msg Message) *discordgo.MessageSend {
return &discordgo.MessageSend{
Embeds: toEmbeds(msg),
AllowedMentions: &discordgo.MessageAllowedMentions{},
}
}
func toEmbeds(msg Message) []*discordgo.MessageEmbed {
main := &discordgo.MessageEmbed{
Title: msg.Title,
URL: msg.URL,
Description: msg.Description,
Color: embedColor,
}
if msg.City != "" {
main.Fields = append(main.Fields, &discordgo.MessageEmbedField{
Name: "City",
Value: msg.City,
Inline: true,
})
}
if msg.Author != "" {
main.Fields = append(main.Fields, &discordgo.MessageEmbedField{
Name: "Author",
Value: msg.Author,
Inline: true,
})
}
embeds := []*discordgo.MessageEmbed{main}
for i, url := range msg.ImageURLs {
if i == 0 {
main.Image = &discordgo.MessageEmbedImage{URL: url}
continue
}
embeds = append(embeds, &discordgo.MessageEmbed{
Color: embedColor,
Image: &discordgo.MessageEmbedImage{URL: url},
})
}
return embeds
}
-178
View File
@@ -1,178 +0,0 @@
package discord
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/store"
)
const discordTimeout = 15 * time.Second
// Bot posts site events to a Discord channel and owns post-to-message links.
type Bot struct {
channelID string
links store.DiscordLinkStore
api API
}
// New constructs an outbound subscriber. Tests inject a fake API.
func New(channelID string, links store.DiscordLinkStore, api API) *Bot {
return &Bot{channelID: strings.TrimSpace(channelID), links: links, api: api}
}
// FromEnv builds a bot when Discord env is set. Missing config is a no-op.
func FromEnv(links store.DiscordLinkStore, bus *events.Bus) (*Bot, error) {
token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN"))
channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID"))
if token == "" && channelID == "" {
return nil, nil
}
if token == "" {
return nil, fmt.Errorf("DISCORD_BOT_TOKEN is required when DISCORD_CHANNEL_ID is set")
}
if channelID == "" {
return nil, fmt.Errorf("DISCORD_CHANNEL_ID is required when DISCORD_BOT_TOKEN is set")
}
if links == nil {
return nil, fmt.Errorf("discord links store is required")
}
session, err := discordgo.New("Bot " + token)
if err != nil {
return nil, err
}
bot := New(channelID, links, &sessionAPI{session: session})
if bus != nil {
bus.Subscribe(bot.Handle)
}
log.Printf("discord: outbound subscriber enabled")
return bot, nil
}
// Close releases the Discord session.
func (b *Bot) Close() error {
if b == nil || b.api == nil {
return nil
}
return b.api.Close()
}
// Handle processes one site event. Failures are logged and do not fail the request.
func (b *Bot) Handle(_ context.Context, ev any) {
if b == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discordTimeout)
defer cancel()
switch e := ev.(type) {
case events.PostCreated:
b.onCreated(ctx, e.PostEvent)
case events.PostUpdated:
b.onUpdated(ctx, e.PostEvent)
}
}
func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
if isRoot(ev) {
b.createRoot(ctx, ev)
return
}
b.createReply(ctx, ev)
}
func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
link, err := b.links.GetByPostID(ctx, ev.PostID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
b.onCreated(ctx, ev)
return
}
log.Printf("discord: load link %s: %v", ev.PostID, err)
return
}
channelID, err := b.editChannel(ctx, ev, link)
if err != nil {
log.Printf("discord: edit channel %s: %v", ev.PostID, err)
return
}
if err := b.api.Edit(ctx, channelID, link.MessageID, formatMessage(ev)); err != nil {
log.Printf("discord: edit %s: %v", ev.PostID, err)
return
}
log.Printf("discord: edited %s", ev.PostID)
}
func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) {
msg := formatMessage(ev)
messageID, err := b.api.SendToChannel(ctx, b.channelID, msg)
if err != nil {
log.Printf("discord: send root %s: %v", ev.PostID, err)
return
}
threadID, err := b.api.StartThread(ctx, b.channelID, messageID, msg.ThreadName)
if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
MessageID: messageID,
ThreadID: threadID,
}); err != nil {
log.Printf("discord: save root link %s: %v", ev.PostID, err)
return
}
log.Printf("discord: posted root %s", ev.PostID)
}
func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) {
root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: skip reply %s: no root thread", ev.PostID)
return
}
log.Printf("discord: load root link %s: %v", ev.RootID, err)
return
}
if strings.TrimSpace(root.ThreadID) == "" {
log.Printf("discord: skip reply %s: no root thread", ev.PostID)
return
}
messageID, err := b.api.SendToThread(ctx, root.ThreadID, formatMessage(ev))
if err != nil {
log.Printf("discord: send reply %s: %v", ev.PostID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: ev.PostID,
MessageID: messageID,
}); err != nil {
log.Printf("discord: save reply link %s: %v", ev.PostID, err)
return
}
log.Printf("discord: posted reply %s", ev.PostID)
}
func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) {
if strings.TrimSpace(link.ThreadID) != "" {
return b.channelID, nil
}
root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil {
return "", err
}
if strings.TrimSpace(root.ThreadID) == "" {
return "", fmt.Errorf("root %s has no thread", ev.RootID)
}
return root.ThreadID, nil
}
-247
View File
@@ -1,247 +0,0 @@
package discord
import (
"context"
"sync"
"testing"
"strconv"
"plumber/internal/events"
"plumber/internal/store"
)
type recordedSend struct {
Kind string
ChannelID string
Name string
Msg Message
}
type fakeAPI struct {
mu sync.Mutex
sends []recordedSend
edits []recordedSend
next int
failSend error
}
func (f *fakeAPI) SendToChannel(_ context.Context, channelID string, msg Message) (string, error) {
return f.record("channel", channelID, "", msg)
}
func (f *fakeAPI) StartThread(_ context.Context, channelID, messageID, name string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.next++
f.sends = append(f.sends, recordedSend{
Kind: "thread",
ChannelID: channelID,
Name: name,
Msg: Message{ThreadName: name, URL: messageID},
})
return "thread-" + messageID, nil
}
func (f *fakeAPI) SendToThread(_ context.Context, threadID string, msg Message) (string, error) {
return f.record("thread-msg", threadID, "", msg)
}
func (f *fakeAPI) Edit(_ context.Context, channelID, messageID string, msg Message) error {
f.mu.Lock()
defer f.mu.Unlock()
f.edits = append(f.edits, recordedSend{
Kind: "edit",
ChannelID: channelID,
Name: messageID,
Msg: msg,
})
return nil
}
func (f *fakeAPI) Close() error { return nil }
func (f *fakeAPI) record(kind, channelID, name string, msg Message) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.failSend != nil {
return "", f.failSend
}
f.next++
id := "msg-" + strconv.Itoa(f.next)
f.sends = append(f.sends, recordedSend{Kind: kind, ChannelID: channelID, Name: name, Msg: msg})
return id, nil
}
func TestOutboundRootReplyAndEdit(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
ctx := context.Background()
root := events.PostEvent{
PostID: "root-1",
RootID: "root-1",
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
AuthorName: "sam",
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-root-1",
Images: []events.Image{{URL: "https://cdn.example/a.jpg"}, {URL: "https://cdn.example/b.jpg"}},
}
bot.Handle(ctx, events.PostCreated{PostEvent: root})
if len(api.sends) != 2 || api.sends[0].Kind != "channel" || api.sends[1].Kind != "thread" {
t.Fatalf("root sends = %+v", api.sends)
}
if api.sends[0].ChannelID != "channel-1" || api.sends[1].Name != "Leaky sink" {
t.Fatalf("root routing = %+v", api.sends)
}
if got := api.sends[0].Msg.ImageURLs; len(got) != 2 || got[0] != "https://cdn.example/a.jpg" {
t.Fatalf("root images = %v", got)
}
link, err := links.GetByPostID(ctx, "root-1")
if err != nil || link.MessageID != "msg-1" || link.ThreadID != "thread-msg-1" {
t.Fatalf("root link = %+v, %v", link, err)
}
reply := events.PostEvent{
PostID: "reply-1",
RootID: "root-1",
ParentID: "root-1",
Body: "Replace the cartridge.",
AuthorName: "plumber",
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-reply-1",
}
bot.Handle(ctx, events.PostCreated{PostEvent: reply})
if len(api.sends) != 3 || api.sends[2].Kind != "thread-msg" || api.sends[2].ChannelID != "thread-msg-1" {
t.Fatalf("reply sends = %+v", api.sends)
}
replyLink, err := links.GetByPostID(ctx, "reply-1")
if err != nil || replyLink.MessageID != "msg-3" || replyLink.ThreadID != "" {
t.Fatalf("reply link = %+v, %v", replyLink, err)
}
root.Body = "Updated leak."
bot.Handle(ctx, events.PostUpdated{PostEvent: root})
if len(api.edits) != 1 || api.edits[0].ChannelID != "channel-1" || api.edits[0].Name != "msg-1" {
t.Fatalf("root edit = %+v", api.edits)
}
if api.edits[0].Msg.Description != "Updated leak." {
t.Fatalf("root edit body = %+v", api.edits[0].Msg)
}
reply.Body = "Use a ceramic cartridge."
bot.Handle(ctx, events.PostUpdated{PostEvent: reply})
if len(api.edits) != 2 || api.edits[1].ChannelID != "thread-msg-1" || api.edits[1].Name != "msg-3" {
t.Fatalf("reply edit = %+v", api.edits)
}
}
func TestOutboundSkipsReplyWithoutRootLink(t *testing.T) {
t.Parallel()
api := &fakeAPI{}
bot := New("channel-1", newMemoryLinks(), api)
bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: "reply-1",
RootID: "missing",
ParentID: "missing",
Body: "Orphan reply",
}})
if len(api.sends) != 0 {
t.Fatalf("unexpected sends %+v", api.sends)
}
}
func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
bot.Handle(context.Background(), events.PostUpdated{PostEvent: events.PostEvent{
PostID: "root-2",
RootID: "root-2",
Title: "Late question",
Body: "Created while Discord was down.",
}})
link, err := links.GetByPostID(context.Background(), "root-2")
if err != nil || link.ThreadID == "" || len(api.sends) != 2 {
t.Fatalf("late create link=%+v sends=%+v err=%v", link, api.sends, err)
}
}
func TestFormatMessage(t *testing.T) {
t.Parallel()
got := formatMessage(events.PostEvent{
Title: "Leaky sink",
Body: "It drips.",
City: "Oakland",
AuthorName: "sam",
Permalink: "https://example.com/q",
Images: []events.Image{{URL: "https://cdn.example/a.jpg", Description: "ignored"}},
})
if got.Title != "Leaky sink" ||
got.Description != "It drips." ||
got.City != "Oakland" ||
got.Author != "sam" ||
got.URL != "https://example.com/q" ||
got.ThreadName != "Leaky sink" ||
len(got.ImageURLs) != 1 {
t.Fatalf("format = %+v", got)
}
reply := formatMessage(events.PostEvent{Body: "Thanks", AuthorName: ""})
if reply.Title != "Reply" || reply.Author != "Someone" || reply.ThreadName != "Question" {
t.Fatalf("reply format = %+v", reply)
}
}
func TestFromEnvDisabled(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "")
bot, err := FromEnv(newMemoryLinks(), nil)
if err != nil || bot != nil {
t.Fatalf("disabled FromEnv = (%v, %v)", bot, err)
}
}
func TestFromEnvRequiresBoth(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "token")
t.Setenv("DISCORD_CHANNEL_ID", "")
if _, err := FromEnv(newMemoryLinks(), nil); err == nil {
t.Fatal("expected error when channel is missing")
}
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "channel")
if _, err := FromEnv(newMemoryLinks(), nil); err == nil {
t.Fatal("expected error when token is missing")
}
}
func TestMemoryLinkUpsertKeepsThread(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
ctx := context.Background()
if err := links.Upsert(ctx, store.DiscordLink{PostID: "p", MessageID: "m1", ThreadID: "t1"}); err != nil {
t.Fatal(err)
}
if err := links.Upsert(ctx, store.DiscordLink{PostID: "p", MessageID: "m2"}); err != nil {
t.Fatal(err)
}
got, err := links.GetByPostID(ctx, "p")
if err != nil || got.MessageID != "m2" || got.ThreadID != "t1" {
t.Fatalf("upsert keep thread = %+v, %v", got, err)
}
if _, err := links.GetByMessageID(ctx, "m2"); err != nil {
t.Fatal(err)
}
if _, err := links.GetRootByThreadID(ctx, "t1"); err != nil {
t.Fatal(err)
}
}
-75
View File
@@ -1,75 +0,0 @@
package discord
import (
"strings"
"plumber/internal/events"
)
const (
embedTitleLimit = 256
embedDescriptionLimit = 4096
threadNameLimit = 100
embedColor = 0xe96a26
)
// Message is a Discord-ready snapshot of a site post event.
type Message struct {
Title string
URL string
Description string
City string
Author string
ImageURLs []string
ThreadName string
}
func formatMessage(ev events.PostEvent) Message {
title := strings.TrimSpace(ev.Title)
if title == "" {
title = "Reply"
}
author := strings.TrimSpace(ev.AuthorName)
if author == "" {
author = "Someone"
}
msg := Message{
Title: truncateRunes(title, embedTitleLimit),
URL: strings.TrimSpace(ev.Permalink),
Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit),
City: strings.TrimSpace(ev.City),
Author: author,
ThreadName: threadName(ev.Title),
}
for _, img := range ev.Images {
url := strings.TrimSpace(img.URL)
if url == "" {
continue
}
msg.ImageURLs = append(msg.ImageURLs, url)
}
return msg
}
func threadName(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return "Question"
}
return truncateRunes(title, threadNameLimit)
}
func truncateRunes(s string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}
func isRoot(ev events.PostEvent) bool {
return strings.TrimSpace(ev.ParentID) == ""
}
-88
View File
@@ -1,88 +0,0 @@
package discord
import (
"context"
"database/sql"
"strings"
"sync"
"time"
"plumber/internal/store"
)
// memoryLinks is an in-process DiscordLinkStore for tests.
type memoryLinks struct {
mu sync.Mutex
byPost map[string]store.DiscordLink
byMessage map[string]string
byThread map[string]string
}
func newMemoryLinks() *memoryLinks {
return &memoryLinks{
byPost: map[string]store.DiscordLink{},
byMessage: map[string]string{},
byThread: map[string]string{},
}
}
func (m *memoryLinks) GetByPostID(_ context.Context, postID string) (*store.DiscordLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
link, ok := m.byPost[strings.TrimSpace(postID)]
if !ok {
return nil, sql.ErrNoRows
}
cp := link
return &cp, nil
}
func (m *memoryLinks) GetByMessageID(_ context.Context, messageID string) (*store.DiscordLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
postID, ok := m.byMessage[strings.TrimSpace(messageID)]
if !ok {
return nil, sql.ErrNoRows
}
link := m.byPost[postID]
cp := link
return &cp, nil
}
func (m *memoryLinks) GetRootByThreadID(_ context.Context, threadID string) (*store.DiscordLink, error) {
m.mu.Lock()
defer m.mu.Unlock()
postID, ok := m.byThread[strings.TrimSpace(threadID)]
if !ok {
return nil, sql.ErrNoRows
}
link := m.byPost[postID]
cp := link
return &cp, nil
}
func (m *memoryLinks) Upsert(_ context.Context, link store.DiscordLink) error {
m.mu.Lock()
defer m.mu.Unlock()
link.PostID = strings.TrimSpace(link.PostID)
link.MessageID = strings.TrimSpace(link.MessageID)
link.ThreadID = strings.TrimSpace(link.ThreadID)
if link.CreatedAt == "" {
link.CreatedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
if prev, ok := m.byPost[link.PostID]; ok {
delete(m.byMessage, prev.MessageID)
if prev.ThreadID != "" {
delete(m.byThread, prev.ThreadID)
}
if link.ThreadID == "" {
link.ThreadID = prev.ThreadID
}
}
m.byPost[link.PostID] = link
m.byMessage[link.MessageID] = link.PostID
if link.ThreadID != "" {
m.byThread[link.ThreadID] = link.PostID
}
return nil
}
-79
View File
@@ -1,79 +0,0 @@
package events
import (
"context"
"log"
"sync"
)
const defaultBuffer = 64
// Publisher is the site-facing write side of the bus.
type Publisher interface {
Publish(ctx context.Context, ev any)
}
// Bus is an in-process pub/sub with one worker and a bounded queue.
type Bus struct {
ch chan any
mu sync.Mutex
subs []func(context.Context, any)
closed sync.Once
}
// New starts a worker that delivers events to subscribers in publish order.
func New() *Bus {
return newBus(defaultBuffer, true)
}
func newBus(buffer int, start bool) *Bus {
if buffer < 1 {
buffer = 1
}
b := &Bus{ch: make(chan any, buffer)}
if start {
go b.loop()
}
return b
}
// Publish enqueues ev. It never blocks the caller; a full buffer is dropped.
func (b *Bus) Publish(_ context.Context, ev any) {
if b == nil {
return
}
select {
case b.ch <- ev:
default:
log.Printf("events: dropped %T", ev)
}
}
// Subscribe adds a handler. Handlers run serially on the worker.
func (b *Bus) Subscribe(fn func(context.Context, any)) {
if b == nil || fn == nil {
return
}
b.mu.Lock()
b.subs = append(b.subs, fn)
b.mu.Unlock()
}
func (b *Bus) loop() {
for ev := range b.ch {
b.mu.Lock()
subs := append([]func(context.Context, any){}, b.subs...)
b.mu.Unlock()
for _, fn := range subs {
fn(context.Background(), ev)
}
}
}
// Close stops the worker. Safe to call more than once.
func (b *Bus) Close() {
if b == nil {
return
}
b.closed.Do(func() { close(b.ch) })
}
-104
View File
@@ -1,104 +0,0 @@
package events
import (
"context"
"sync"
"testing"
"time"
)
func TestPermalink(t *testing.T) {
t.Parallel()
tests := []struct {
base, root, post, want string
}{
{"", "root-1", "post-2", "/questions/root-1#post-post-2"},
{"https://www.askaplumberfirst.com/", "root-1", "post-2", "https://www.askaplumberfirst.com/questions/root-1#post-post-2"},
{"https://www.askaplumberfirst.com", "a b", "c/d", "https://www.askaplumberfirst.com/questions/a%20b#post-c%2Fd"},
}
for _, tc := range tests {
if got := Permalink(tc.base, tc.root, tc.post); got != tc.want {
t.Fatalf("Permalink(%q, %q, %q) = %q, want %q", tc.base, tc.root, tc.post, got, tc.want)
}
}
}
func TestNopAndRecording(t *testing.T) {
t.Parallel()
Nop{}.Publish(context.Background(), PostCreated{})
rec := &Recording{}
rec.Publish(context.Background(), PostCreated{PostEvent: PostEvent{PostID: "a"}})
rec.Publish(context.Background(), PostUpdated{PostEvent: PostEvent{PostID: "b"}})
if rec.Len() != 2 {
t.Fatalf("len = %d", rec.Len())
}
got := rec.Snapshot()
created, ok := got[0].(PostCreated)
if !ok || created.PostID != "a" {
t.Fatalf("first = %#v", got[0])
}
updated, ok := got[1].(PostUpdated)
if !ok || updated.PostID != "b" {
t.Fatalf("second = %#v", got[1])
}
}
func TestBusDeliversInOrder(t *testing.T) {
t.Parallel()
bus := New()
defer bus.Close()
var mu sync.Mutex
var got []string
done := make(chan struct{})
bus.Subscribe(func(_ context.Context, ev any) {
mu.Lock()
got = append(got, ev.(string))
if len(got) == 3 {
close(done)
}
mu.Unlock()
})
ctx := context.Background()
bus.Publish(ctx, "one")
bus.Publish(ctx, "two")
bus.Publish(ctx, "three")
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timed out waiting for events")
}
mu.Lock()
defer mu.Unlock()
if len(got) != 3 || got[0] != "one" || got[1] != "two" || got[2] != "three" {
t.Fatalf("got %v", got)
}
}
func TestBusDropsWhenFull(t *testing.T) {
t.Parallel()
bus := newBus(1, false)
bus.Publish(context.Background(), "kept")
bus.Publish(context.Background(), "dropped")
select {
case ev := <-bus.ch:
if ev != "kept" {
t.Fatalf("got %v", ev)
}
default:
t.Fatal("expected buffered event")
}
select {
case ev := <-bus.ch:
t.Fatalf("unexpected extra event %v", ev)
default:
}
}
-47
View File
@@ -1,47 +0,0 @@
package events
import (
"net/url"
"strings"
)
// Image is a public photo already attached to a site post.
type Image struct {
URL string
Description string
}
// PostEvent is a Discord-free snapshot of a site post after a successful write.
type PostEvent struct {
PostID string
RootID string
ParentID string
Title string
Body string
City string
AuthorID string
AuthorName string
AuthorRole string
Images []Image
Permalink string
}
// PostCreated is emitted after a successful site create.
type PostCreated struct {
PostEvent
}
// PostUpdated is emitted after a successful site edit.
type PostUpdated struct {
PostEvent
}
// Permalink builds /questions/{root}#post-{id}, prefixed by baseURL when set.
func Permalink(baseURL, rootID, postID string) string {
path := "/questions/" + url.PathEscape(rootID) + "#post-" + url.PathEscape(postID)
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
return path
}
return base + path
}
-9
View File
@@ -1,9 +0,0 @@
package events
import "context"
// Nop is a Publisher used when nothing is subscribed.
type Nop struct{}
// Publish discards ev.
func (Nop) Publish(context.Context, any) {}
-44
View File
@@ -1,44 +0,0 @@
package events
import (
"context"
"sync"
)
// Recording is a test Publisher that records events synchronously.
type Recording struct {
mu sync.Mutex
evs []any
}
// Publish appends ev.
func (r *Recording) Publish(_ context.Context, ev any) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.evs = append(r.evs, ev)
}
// Len returns the number of recorded events.
func (r *Recording) Len() int {
if r == nil {
return 0
}
r.mu.Lock()
defer r.mu.Unlock()
return len(r.evs)
}
// Snapshot returns a copy of recorded events.
func (r *Recording) Snapshot() []any {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
out := make([]any, len(r.evs))
copy(out, r.evs)
return out
}
-105
View File
@@ -1,105 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"plumber/internal/store/sqlc"
)
// DiscordLink is the bot-owned mapping from a site post to a Discord message.
type DiscordLink struct {
PostID string
MessageID string
ThreadID string
CreatedAt string
}
// DiscordLinkStore is the mapping table used only by the Discord subscriber.
// It is not part of Store.
type DiscordLinkStore interface {
GetByPostID(ctx context.Context, postID string) (*DiscordLink, error)
GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error)
GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error)
Upsert(ctx context.Context, link DiscordLink) error
}
// DiscordLinks implements DiscordLinkStore against Postgres.
type DiscordLinks struct {
db *sql.DB
}
// NewDiscordLinks wraps db. It is independent of Store.
func NewDiscordLinks(db *sql.DB) *DiscordLinks {
return &DiscordLinks{db: db}
}
// GetByPostID returns the link for a site post.
func (d *DiscordLinks) GetByPostID(ctx context.Context, postID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByPostID(ctx, strings.TrimSpace(postID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// GetByMessageID returns the link for a Discord message.
func (d *DiscordLinks) GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByMessageID(ctx, strings.TrimSpace(messageID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// GetRootByThreadID returns the root link for a Discord thread.
func (d *DiscordLinks) GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByThreadID(ctx, strings.TrimSpace(threadID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// Upsert inserts or replaces the Discord IDs for a post.
func (d *DiscordLinks) Upsert(ctx context.Context, link DiscordLink) error {
if d == nil || d.db == nil {
return fmt.Errorf("discord links: no database")
}
link.PostID = strings.TrimSpace(link.PostID)
link.MessageID = strings.TrimSpace(link.MessageID)
link.ThreadID = strings.TrimSpace(link.ThreadID)
if link.PostID == "" || link.MessageID == "" {
return fmt.Errorf("discord links: post and message ids are required")
}
if link.CreatedAt == "" {
link.CreatedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
return sqlc.New(d.db).UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: link.PostID,
DiscordMessageID: link.MessageID,
DiscordThreadID: link.ThreadID,
CreatedAt: link.CreatedAt,
})
}
func discordLinkFromRow(row sqlc.DiscordPostLink) *DiscordLink {
return &DiscordLink{
PostID: row.PostID,
MessageID: row.DiscordMessageID,
ThreadID: row.DiscordThreadID,
CreatedAt: row.CreatedAt,
}
}
-20
View File
@@ -139,25 +139,6 @@ CREATE TABLE IF NOT EXISTS post_images (
return nil
}
func migrateDiscordPostLinks(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS discord_post_links (
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
discord_message_id TEXT NOT NULL UNIQUE,
discord_thread_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("create discord_post_links: %w", err)
}
if _, err := exec.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
ON discord_post_links (discord_thread_id)
WHERE discord_thread_id <> ''`); err != nil {
return fmt.Errorf("discord_post_links_thread_uidx: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -350,7 +331,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks},
}
for _, m := range migrations {
if applied[m.version] {
-42
View File
@@ -81,12 +81,6 @@ CREATE TABLE users (
if err := migratePostImages(ctx, conn); err != nil {
t.Fatalf("post images migration is not idempotent: %v", err)
}
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
@@ -149,42 +143,6 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
}); err == nil {
t.Fatal("fifth image position unexpectedly succeeded")
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1",
DiscordMessageID: "msg-root",
DiscordThreadID: "thread-root",
CreatedAt: "2026-08-26T08:00:00Z",
}); err != nil {
t.Fatal(err)
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "reply-1",
DiscordMessageID: "msg-reply",
DiscordThreadID: "",
CreatedAt: "2026-08-26T09:00:00Z",
}); err != nil {
t.Fatal(err)
}
rootLink, err := imageQueries.GetDiscordPostLinkByPostID(ctx, "root-1")
if err != nil || rootLink.DiscordMessageID != "msg-root" || rootLink.DiscordThreadID != "thread-root" {
t.Fatalf("root discord link = %+v, %v", rootLink, err)
}
threadLink, err := imageQueries.GetDiscordPostLinkByThreadID(ctx, "thread-root")
if err != nil || threadLink.PostID != "root-1" {
t.Fatalf("thread discord link = %+v, %v", threadLink, err)
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "reply-1",
DiscordMessageID: "msg-reply-2",
DiscordThreadID: "",
CreatedAt: "2026-08-26T09:01:00Z",
}); err != nil {
t.Fatal(err)
}
replyLink, err := imageQueries.GetDiscordPostLinkByPostID(ctx, "reply-1")
if err != nil || replyLink.DiscordMessageID != "msg-reply-2" || replyLink.DiscordThreadID != "" {
t.Fatalf("reply upsert = %+v, %v", replyLink, err)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
-100
View File
@@ -1,100 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: discord_links.sql
package sqlc
import (
"context"
)
const getDiscordPostLinkByMessageID = `-- name: GetDiscordPostLinkByMessageID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_message_id = $1
`
func (q *Queries) GetDiscordPostLinkByMessageID(ctx context.Context, discordMessageID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByMessageID, discordMessageID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const getDiscordPostLinkByPostID = `-- name: GetDiscordPostLinkByPostID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE post_id = $1
`
func (q *Queries) GetDiscordPostLinkByPostID(ctx context.Context, postID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByPostID, postID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const getDiscordPostLinkByThreadID = `-- name: GetDiscordPostLinkByThreadID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_thread_id = $1
AND discord_thread_id <> ''
`
func (q *Queries) GetDiscordPostLinkByThreadID(ctx context.Context, discordThreadID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByThreadID, discordThreadID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const upsertDiscordPostLink = `-- name: UpsertDiscordPostLink :exec
INSERT INTO discord_post_links (
post_id, discord_message_id, discord_thread_id, created_at
)
VALUES (
$1,
$2,
$3,
$4
)
ON CONFLICT (post_id) DO UPDATE SET
discord_message_id = EXCLUDED.discord_message_id,
discord_thread_id = CASE
WHEN EXCLUDED.discord_thread_id <> '' THEN EXCLUDED.discord_thread_id
ELSE discord_post_links.discord_thread_id
END
`
type UpsertDiscordPostLinkParams struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
}
func (q *Queries) UpsertDiscordPostLink(ctx context.Context, arg UpsertDiscordPostLinkParams) error {
_, err := q.db.ExecContext(ctx, upsertDiscordPostLink,
arg.PostID,
arg.DiscordMessageID,
arg.DiscordThreadID,
arg.CreatedAt,
)
return err
}
-7
View File
@@ -9,13 +9,6 @@ import (
"time"
)
type DiscordPostLink struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
}
type Post struct {
ID string
ParentID sql.NullString
-65
View File
@@ -1,65 +0,0 @@
package web
import (
"context"
"plumber/internal/events"
"plumber/internal/store"
)
func (s *Server) publishPostCreated(post, root *store.Post, author *store.User) {
s.publishPost(events.PostCreated{PostEvent: s.postEvent(post, root, author)}, root)
}
func (s *Server) publishPostUpdated(post, root *store.Post, author *store.User) {
s.publishPost(events.PostUpdated{PostEvent: s.postEvent(post, root, author)}, root)
}
func (s *Server) publishPost(ev any, root *store.Post) {
if root != nil && root.PostState == store.PostStateHidden {
return
}
s.cfg.Events.Publish(context.Background(), ev)
}
func (s *Server) postEvent(post, root *store.Post, author *store.User) events.PostEvent {
if post == nil {
return events.PostEvent{}
}
rootID := post.ID
if root != nil {
rootID = root.ID
}
ev := events.PostEvent{
PostID: post.ID,
RootID: rootID,
Title: post.Title,
Body: post.Body,
City: post.City,
AuthorID: post.AuthorID,
AuthorName: post.AuthorName,
AuthorRole: string(post.AuthorRole),
Permalink: events.Permalink(s.cfg.BaseURL, rootID, post.ID),
}
if post.ParentID != nil {
ev.ParentID = *post.ParentID
}
if author != nil {
if ev.AuthorName == "" {
ev.AuthorName = author.Name
}
if ev.AuthorRole == "" {
ev.AuthorRole = string(author.Role)
}
}
if n := len(post.Images); n > 0 {
ev.Images = make([]events.Image, 0, n)
for _, img := range post.Images {
ev.Images = append(ev.Images, events.Image{
URL: img.PublicURL,
Description: img.Description,
})
}
}
return ev
}
-238
View File
@@ -1,238 +0,0 @@
package web
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"plumber/internal/events"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestPostHandlersPublishEvents(t *testing.T) {
t.Parallel()
rec := &events.Recording{}
srv, mem := newTestServer(t, Config{
Events: rec,
BaseURL: "https://www.askaplumberfirst.com",
})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
homeownerCookies := loginUser(t, handler, homeowner.Username, "hunter22")
adminCookies := loginUser(t, handler, admin.Username, "hunter22")
homeownerCSRF := csrfForCookies(t, handler, homeownerCookies)
adminCSRF := csrfForCookies(t, handler, adminCookies)
submit := postForm(handler, "/submit", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Leaky sink"},
"body": {"Water under the cabinet."},
"city": {"Oakland"},
}, homeownerCookies)
if submit.Code != http.StatusSeeOther {
t.Fatalf("submit status = %d: %s", submit.Code, submit.Body.String())
}
create := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Second question"},
"body": {"Another leak."},
"city": {"Berkeley"},
}, homeownerCookies)
if create.Code != http.StatusSeeOther {
t.Fatalf("create status = %d: %s", create.Code, create.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 2 {
t.Fatalf("roots = %+v, %v", roots, err)
}
var submitRoot, createRoot store.Post
for _, root := range roots {
switch root.Title {
case "Leaky sink":
submitRoot = root
case "Second question":
createRoot = root
}
}
if submitRoot.ID == "" || createRoot.ID == "" {
t.Fatalf("missing created roots: %+v", roots)
}
reply := postForm(handler, "/posts", url.Values{
"_csrf": {adminCSRF},
"parent_id": {createRoot.ID},
"body": {"Replace the cartridge."},
}, adminCookies)
if reply.Code != http.StatusSeeOther {
t.Fatalf("reply status = %d: %s", reply.Code, reply.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), createRoot.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
adminReply := thread.Replies[0]
edit := postForm(handler, "/posts/"+createRoot.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Updated leak description."},
}, homeownerCookies)
if edit.Code != http.StatusSeeOther {
t.Fatalf("edit status = %d: %s", edit.Code, edit.Body.String())
}
empty := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"title": {"Missing body"},
}, homeownerCookies)
if empty.Code != http.StatusBadRequest {
t.Fatalf("empty body status = %d, want 400", empty.Code)
}
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden thread",
Body: "Not public.",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
hiddenReply := postForm(handler, "/posts", url.Values{
"_csrf": {homeownerCSRF},
"parent_id": {hidden.ID},
"body": {"Should not publish."},
}, homeownerCookies)
if hiddenReply.Code != http.StatusNotFound {
t.Fatalf("hidden reply status = %d, want 404", hiddenReply.Code)
}
hiddenEdit := postForm(handler, "/posts/"+hidden.ID+"/edit", url.Values{
"_csrf": {homeownerCSRF},
"body": {"Still hidden."},
}, homeownerCookies)
if hiddenEdit.Code != http.StatusSeeOther {
t.Fatalf("hidden edit status = %d: %s", hiddenEdit.Code, hiddenEdit.Body.String())
}
got := rec.Snapshot()
if len(got) != 4 {
t.Fatalf("published %d events, want 4: %#v", len(got), got)
}
submitEv, ok := got[0].(events.PostCreated)
if !ok {
t.Fatalf("first event %T, want PostCreated", got[0])
}
assertPostEvent(t, submitEv.PostEvent, events.PostEvent{
PostID: submitRoot.ID,
RootID: submitRoot.ID,
Title: "Leaky sink",
Body: "Water under the cabinet.",
City: "Oakland",
AuthorID: homeowner.ID,
AuthorName: homeowner.Name,
AuthorRole: string(store.RoleUser),
Permalink: "https://www.askaplumberfirst.com/questions/" + submitRoot.ID + "#post-" + submitRoot.ID,
})
createEv, ok := got[1].(events.PostCreated)
if !ok {
t.Fatalf("second event %T, want PostCreated", got[1])
}
assertPostEvent(t, createEv.PostEvent, events.PostEvent{
PostID: createRoot.ID,
RootID: createRoot.ID,
Title: "Second question",
Body: "Another leak.",
City: "Berkeley",
AuthorID: homeowner.ID,
AuthorName: homeowner.Name,
AuthorRole: string(store.RoleUser),
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + createRoot.ID,
})
replyEv, ok := got[2].(events.PostCreated)
if !ok {
t.Fatalf("third event %T, want PostCreated", got[2])
}
assertPostEvent(t, replyEv.PostEvent, events.PostEvent{
PostID: adminReply.ID,
RootID: createRoot.ID,
ParentID: createRoot.ID,
Body: "Replace the cartridge.",
AuthorID: admin.ID,
AuthorName: admin.Name,
AuthorRole: string(store.RoleAdmin),
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + adminReply.ID,
})
editEv, ok := got[3].(events.PostUpdated)
if !ok {
t.Fatalf("fourth event %T, want PostUpdated", got[3])
}
assertPostEvent(t, editEv.PostEvent, events.PostEvent{
PostID: createRoot.ID,
RootID: createRoot.ID,
Title: "Second question",
Body: "Updated leak description.",
City: "Berkeley",
AuthorID: homeowner.ID,
AuthorName: homeowner.Name,
AuthorRole: string(store.RoleUser),
Permalink: "https://www.askaplumberfirst.com/questions/" + createRoot.ID + "#post-" + createRoot.ID,
})
for i, ev := range got {
raw, err := json.Marshal(ev)
if err != nil {
t.Fatal(err)
}
if strings.Contains(strings.ToLower(string(raw)), "discord") {
t.Fatalf("event %d contains discord fields: %s", i, raw)
}
}
}
func TestStoreCreateDoesNotPublish(t *testing.T) {
t.Parallel()
rec := &events.Recording{}
_, mem := newTestServer(t, Config{Events: rec})
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
if err := mem.CreatePost(context.Background(), &store.Post{
AuthorID: homeowner.ID,
Title: "Direct write",
Body: "No handler.",
PostDate: pacific.Today(),
}); err != nil {
t.Fatal(err)
}
if rec.Len() != 0 {
t.Fatalf("store.CreatePost published %d events", rec.Len())
}
}
func assertPostEvent(t *testing.T, got, want events.PostEvent) {
t.Helper()
if got.PostID != want.PostID ||
got.RootID != want.RootID ||
got.ParentID != want.ParentID ||
got.Title != want.Title ||
got.Body != want.Body ||
got.City != want.City ||
got.AuthorID != want.AuthorID ||
got.AuthorName != want.AuthorName ||
got.AuthorRole != want.AuthorRole ||
got.Permalink != want.Permalink ||
len(got.Images) != 0 {
t.Fatalf("event = %+v, want %+v", got, want)
}
}
+395
View File
@@ -0,0 +1,395 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"plumber/internal/blob"
"plumber/internal/store"
)
const (
defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20
postImageMaxRequestBytes = 22 << 20
postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000
postImageMaxRenderedDim = 1600
postImageCleanupTimeout = 10 * time.Second
)
func requestBodyLimit(r *http.Request) int64 {
if r.Method != http.MethodPost {
return defaultRequestBodyBytes
}
switch {
case r.URL.Path == "/submit", r.URL.Path == "/posts":
return postImageMaxRequestBytes
case strings.HasPrefix(r.URL.Path, "/posts/") && strings.HasSuffix(r.URL.Path, "/edit"):
return postImageMaxRequestBytes
default:
return defaultRequestBodyBytes
}
}
type postImageRequestError struct {
status int
message string
cause error
}
func (e *postImageRequestError) Error() string {
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func parsePostMutationForm(w http.ResponseWriter, r *http.Request) (func(), bool) {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
return func() {}, false
}
if mediaType != "multipart/form-data" {
if err := r.ParseForm(); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
return func() {}, true
}
if err := r.ParseMultipartForm(postImageMultipartMemory); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
cleanup := func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}
return cleanup, true
}
func writePostImageRequestError(w http.ResponseWriter, err error) {
var requestErr *postImageRequestError
if errors.As(err, &requestErr) {
http.Error(w, requestErr.message, requestErr.status)
return
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "Image upload is too large.", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
}
func (s *Server) postImagesFromForm(
ctx context.Context,
r *http.Request,
postID string,
existing []store.PostImage,
) ([]store.PostImage, []string, error) {
if r.MultipartForm == nil {
return append([]store.PostImage(nil), existing...), nil, nil
}
retained, err := retainedPostImages(r.MultipartForm, existing)
if err != nil {
return nil, nil, err
}
files := r.MultipartForm.File["images"]
descriptions := r.MultipartForm.Value["image_description"]
if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
}
if len(retained)+len(files) > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Image uploads are not configured on this server.",
}
}
images := append([]store.PostImage(nil), retained...)
newKeys := make([]string, 0, len(files))
for i, header := range files {
description := ""
if i < len(descriptions) {
description = strings.TrimSpace(descriptions[i])
}
if len([]rune(description)) > store.MaxImageDescriptionRunes {
s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
prepared, err := preparePostImage(header)
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
ContentType: prepared.contentType,
Size: int64(len(prepared.body)),
})
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
newKeys = append(newKeys, objectKey)
images = append(images, store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Width: prepared.width,
Height: prepared.height,
})
}
return images, newKeys, nil
}
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
byID := make(map[string]store.PostImage, len(existing))
for _, image := range existing {
byID[image.ID] = image
}
ids := form.Value["existing_image_id"]
descriptions := form.Value["existing_image_description"]
if len(descriptions) > len(ids) {
return nil, invalidPostImage("Existing image descriptions do not match the images.", nil)
}
seen := make(map[string]bool, len(ids))
retained := make([]store.PostImage, 0, len(ids))
for i, id := range ids {
id = strings.TrimSpace(id)
image, ok := byID[id]
if !ok || seen[id] {
return nil, invalidPostImage("An existing image selection is invalid.", nil)
}
seen[id] = true
if i < len(descriptions) {
image.Description = strings.TrimSpace(descriptions[i])
}
if len([]rune(image.Description)) > store.MaxImageDescriptionRunes {
return nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
retained = append(retained, image)
}
return retained, nil
}
func invalidPostImage(message string, cause error) error {
return &postImageRequestError{status: http.StatusBadRequest, message: message, cause: cause}
}
type preparedPostImage struct {
body []byte
extension string
contentType string
width int
height int
}
func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
if header == nil {
return preparedPostImage{}, invalidPostImage("Select a valid image.", nil)
}
if header.Size > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postImageMaxFileBytes+1))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
if len(raw) == 0 {
return preparedPostImage{}, invalidPostImage("Images cannot be empty.", nil)
}
if int64(len(raw)) > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
sniffed := http.DetectContentType(raw)
switch sniffed {
case "image/jpeg", "image/png", "image/webp":
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > postImageMaxSourceDim || cfg.Height > postImageMaxSourceDim ||
int64(cfg.Width)*int64(cfg.Height) > postImageMaxSourcePixels {
return preparedPostImage{}, invalidPostImage("Image dimensions are too large.", nil)
}
decoded, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if format != "" {
decodedFormat = format
}
if sniffed == "image/jpeg" {
decoded = orientPostImage(decoded, jpegOrientation(raw))
}
decoded = fitPostImage(decoded, postImageMaxRenderedDim)
var out bytes.Buffer
result := preparedPostImage{}
switch decodedFormat {
case "jpeg":
if err := jpeg.Encode(&out, decoded, &jpeg.Options{Quality: 85}); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".jpg"
result.contentType = "image/jpeg"
case "png", "webp":
if err := png.Encode(&out, decoded); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".png"
result.contentType = "image/png"
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
result.body = out.Bytes()
result.width = decoded.Bounds().Dx()
result.height = decoded.Bounds().Dy()
return result, nil
}
func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil {
return 1
}
tag, err := metadata.Get(exif.Orientation)
if err != nil {
return 1
}
orientation, err := tag.Int(0)
if err != nil || orientation < 1 || orientation > 8 {
return 1
}
return orientation
}
func orientPostImage(source image.Image, orientation int) image.Image {
if orientation <= 1 || orientation > 8 {
return source
}
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
targetWidth, targetHeight := width, height
if orientation >= 5 {
targetWidth, targetHeight = height, width
}
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
sourceX, sourceY := x, y
switch orientation {
case 2:
sourceX = width - 1 - x
case 3:
sourceX, sourceY = width-1-x, height-1-y
case 4:
sourceY = height - 1 - y
case 5:
sourceX, sourceY = y, x
case 6:
sourceX, sourceY = y, height-1-x
case 7:
sourceX, sourceY = width-1-y, height-1-x
case 8:
sourceX, sourceY = width-1-y, x
}
target.Set(x, y, source.At(bounds.Min.X+sourceX, bounds.Min.Y+sourceY))
}
}
return target
}
func fitPostImage(source image.Image, maxDimension int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDimension && height <= maxDimension {
return source
}
scale := float64(maxDimension) / float64(width)
if float64(height)*scale > float64(maxDimension) {
scale = float64(maxDimension) / float64(height)
}
targetWidth := max(1, int(float64(width)*scale))
targetHeight := max(1, int(float64(height)*scale))
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.CatmullRom.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
return target
}
func (s *Server) deletePostImageObjects(keys []string) {
if len(keys) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), postImageCleanupTimeout)
defer cancel()
for _, key := range keys {
if err := s.cfg.Blob.Delete(ctx, key); err != nil {
log.Printf("delete post image %s: %v", key, err)
}
}
}
func removedPostImageKeys(before, after []store.PostImage) []string {
retained := make(map[string]bool, len(after))
for _, image := range after {
retained[image.ObjectKey] = true
}
var removed []string
for _, image := range before {
if !retained[image.ObjectKey] {
removed = append(removed, image.ObjectKey)
}
}
return removed
}
+443
View File
@@ -0,0 +1,443 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"plumber/internal/blob"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestPreparePostImage(t *testing.T) {
t.Parallel()
wide := solidPNG(t, 2000, 1000)
prepared, err := preparePostImageHeader(t, "wide.png", wide)
if err != nil {
t.Fatal(err)
}
if prepared.width != 1600 || prepared.height != 800 ||
prepared.extension != ".png" || prepared.contentType != "image/png" {
t.Fatalf("prepared PNG = %+v", prepared)
}
jpegBody := solidJPEG(t, 40, 20)
prepared, err = preparePostImageHeader(t, "photo.jpg", jpegBody)
if err != nil {
t.Fatal(err)
}
if prepared.width != 40 || prepared.height != 20 ||
prepared.extension != ".jpg" || prepared.contentType != "image/jpeg" {
t.Fatalf("prepared JPEG = %+v", prepared)
}
if _, err := preparePostImageHeader(t, "notes.txt", []byte("not an image")); err == nil {
t.Fatal("text upload unexpectedly succeeded")
}
_, err = preparePostImageHeader(t, "too-large.jpg", make([]byte, postImageMaxFileBytes+1))
var requestErr *postImageRequestError
if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized file error = %v, want 413 request error", err)
}
if _, err := preparePostImageHeader(t, "too-wide.png", solidPNG(t, 6001, 1)); err == nil {
t.Fatal("oversized dimensions unexpectedly succeeded")
}
}
func TestOrientPostImage(t *testing.T) {
t.Parallel()
source := image.NewNRGBA(image.Rect(0, 0, 2, 1))
source.Set(0, 0, color.NRGBA{R: 255, A: 255})
source.Set(1, 0, color.NRGBA{B: 255, A: 255})
rotated := orientPostImage(source, 6)
if rotated.Bounds().Dx() != 1 || rotated.Bounds().Dy() != 2 {
t.Fatalf("rotated bounds = %v", rotated.Bounds())
}
top := color.NRGBAModel.Convert(rotated.At(0, 0)).(color.NRGBA)
bottom := color.NRGBAModel.Convert(rotated.At(0, 1)).(color.NRGBA)
if top.R != 255 || bottom.B != 255 {
t.Fatalf("rotation colors top=%v bottom=%v", top, bottom)
}
}
func TestPostImageMultipartLifecycle(t *testing.T) {
t.Parallel()
images := &recordingImageBlob{}
srv, mem := newTestServer(t, Config{Blob: images})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("images"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, homeowner.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Leaky valve"},
"body": {"Two views of the leak."},
"city": {"Oakland"},
"image_description": {"Front view", "Under the sink"},
}, []multipartTestFile{
{name: "front.png", body: solidPNG(t, 80, 40)},
{name: "under.jpg", body: solidJPEG(t, 40, 80)},
}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root image upload status = %d: %s", rec.Code, rec.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("roots = %+v, %v", roots, err)
}
root, err := mem.GetPost(context.Background(), roots[0].ID)
if err != nil {
t.Fatal(err)
}
if len(root.Images) != 2 ||
root.Images[0].Description != "Front view" ||
root.Images[1].Description != "Under the sink" {
t.Fatalf("root images = %+v", root.Images)
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Here is the model label."},
"image_description": {"Model label"},
}, []multipartTestFile{{name: "label.png", body: solidPNG(t, 60, 30)}}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("reply image upload status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
reply := thread.Replies[0]
if len(reply.Images) != 1 || reply.Images[0].Description != "Model label" {
t.Fatalf("reply images = %+v", reply.Images)
}
originalKey := reply.Images[0].ObjectKey
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Updated label photos."},
"existing_image_id": {reply.Images[0].ID},
"existing_image_description": {"Existing label"},
"image_description": {"Serial number"},
}, []multipartTestFile{{name: "serial.jpg", body: solidJPEG(t, 50, 25)}}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("image edit status = %d: %s", rec.Code, rec.Body.String())
}
edited, err := mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 2 ||
edited.Images[0].Description != "Existing label" ||
edited.Images[1].Description != "Serial number" {
t.Fatalf("edited images = %+v", edited.Images)
}
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Keep only the serial number."},
"existing_image_id": {edited.Images[1].ID},
"existing_image_description": {"Serial number"},
}, nil, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("image removal status = %d: %s", rec.Code, rec.Body.String())
}
edited, err = mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 1 || edited.Images[0].Description != "Serial number" {
t.Fatalf("images after removal = %+v", edited.Images)
}
if !images.wasDeleted(originalKey) {
t.Fatalf("removed object %q was not deleted: %+v", originalKey, images.deletedKeys())
}
uploadsBefore := images.uploadCount()
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Invalid retained image."},
"existing_image_id": {"not-owned"},
}, nil, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid retained image status = %d, want 400", rec.Code)
}
unchanged, err := mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if unchanged.Body != "Keep only the serial number." || len(unchanged.Images) != 1 {
t.Fatalf("invalid retained image changed post: %+v", unchanged)
}
files := make([]multipartTestFile, store.MaxPostImages+1)
for i := range files {
files[i] = multipartTestFile{name: "extra.png", body: solidPNG(t, 10, 10)}
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Too many images."},
}, files, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("five-image status = %d, want 400", rec.Code)
}
if images.uploadCount() != uploadsBefore {
t.Fatal("five-image request uploaded objects before rejecting count")
}
}
func TestPostImageUploadCompensation(t *testing.T) {
t.Parallel()
t.Run("blob failure deletes earlier upload", func(t *testing.T) {
images := &recordingImageBlob{failAt: 2}
srv, mem := newTestServer(t, Config{Blob: images})
handler := srv.Handler()
user := seedUser(t, mem, uniq("blob-fail"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Upload failure"},
"body": {"Should not persist."},
}, []multipartTestFile{
{name: "one.png", body: solidPNG(t, 10, 10)},
{name: "two.png", body: solidPNG(t, 10, 10)},
}, cookies)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("blob failure status = %d: %s", rec.Code, rec.Body.String())
}
if images.uploadCount() != 1 || len(images.deletedKeys()) != 1 {
t.Fatalf("blob compensation uploads=%d deletes=%v", images.uploadCount(), images.deletedKeys())
}
})
t.Run("store failure deletes uploaded object", func(t *testing.T) {
images := &recordingImageBlob{}
mem := store.NewMemory()
failing := &failingCreatePostStore{Store: mem}
srv := newTestServerStore(t, failing, Config{Blob: images})
handler := srv.Handler()
user := seedUser(t, mem, uniq("store-fail"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Store failure"},
"body": {"Should clean up."},
}, []multipartTestFile{{name: "one.png", body: solidPNG(t, 10, 10)}}, cookies)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("store failure status = %d: %s", rec.Code, rec.Body.String())
}
if images.uploadCount() != 1 || len(images.deletedKeys()) != 1 {
t.Fatalf("store compensation uploads=%d deletes=%v", images.uploadCount(), images.deletedKeys())
}
})
}
func TestPostImageRequestLimits(t *testing.T) {
t.Parallel()
for _, test := range []struct {
method string
path string
want int64
}{
{http.MethodPost, "/submit", postImageMaxRequestBytes},
{http.MethodPost, "/posts", postImageMaxRequestBytes},
{http.MethodPost, "/posts/id/edit", postImageMaxRequestBytes},
{http.MethodPost, "/login", defaultRequestBodyBytes},
{http.MethodGet, "/posts", defaultRequestBodyBytes},
} {
req := httptest.NewRequest(test.method, test.path, nil)
if got := requestBodyLimit(req); got != test.want {
t.Errorf("%s %s limit = %d, want %d", test.method, test.path, got, test.want)
}
}
}
type multipartTestFile struct {
name string
body []byte
}
func multipartPost(
t *testing.T,
handler http.Handler,
requestPath string,
fields map[string][]string,
files []multipartTestFile,
cookies []*http.Cookie,
) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for name, values := range fields {
for _, value := range values {
if err := writer.WriteField(name, value); err != nil {
t.Fatal(err)
}
}
}
for _, file := range files {
part, err := writer.CreateFormFile("images", file.name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(file.body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, requestPath, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) {
t.Helper()
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("images", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(body); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/posts", &requestBody)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(postImageMultipartMemory); err != nil {
t.Fatal(err)
}
defer req.MultipartForm.RemoveAll()
return preparePostImage(req.MultipartForm.File["images"][0])
}
func solidPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.NRGBA{R: 30, G: 90, B: 140, A: 255})
}
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
func solidJPEG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.NRGBA{R: 140, G: 90, B: 30, A: 255})
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
type recordedImageUpload struct {
key string
contentType string
body []byte
}
type recordingImageBlob struct {
mu sync.Mutex
calls int
failAt int
uploads []recordedImageUpload
deletes []string
}
func (b *recordingImageBlob) Enabled() bool { return true }
func (b *recordingImageBlob) Upload(_ context.Context, object blob.FileUpload) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.calls++
if b.failAt > 0 && b.calls == b.failAt {
return "", errors.New("injected upload failure")
}
body, err := io.ReadAll(object.Body)
if err != nil {
return "", err
}
b.uploads = append(b.uploads, recordedImageUpload{
key: object.Key,
contentType: object.ContentType,
body: body,
})
return "https://cdn.example/" + object.Key, nil
}
func (b *recordingImageBlob) Delete(_ context.Context, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
b.deletes = append(b.deletes, key)
return nil
}
func (b *recordingImageBlob) uploadCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.uploads)
}
func (b *recordingImageBlob) deletedKeys() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string(nil), b.deletes...)
}
func (b *recordingImageBlob) wasDeleted(key string) bool {
for _, deleted := range b.deletedKeys() {
if deleted == key {
return true
}
}
return false
}
type failingCreatePostStore struct {
store.Store
}
func (f *failingCreatePostStore) CreatePost(context.Context, *store.Post) error {
return errors.New("injected store failure")
}
+28 -2
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"plumber/internal/mail"
"plumber/internal/store"
@@ -20,6 +21,11 @@ import (
// handleCreatePost creates either a root question or a reply. Replies are
// limited to the root author and admins, and cannot be added to hidden threads.
func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -37,6 +43,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
}
post := &store.Post{
ID: uuid.NewString(),
AuthorID: user.ID,
Body: truncateRunes(body, 12000),
}
@@ -71,7 +78,14 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
root = threadRoot
}
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Images = images
if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
@@ -85,7 +99,6 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
s.publishPostCreated(post, root, user)
http.Redirect(
w,
r,
@@ -150,6 +163,11 @@ func (s *Server) notifyPostReply(
// handleEditPost updates only a post's body after verifying that the current
// homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -178,8 +196,16 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "post body required", http.StatusBadRequest)
return
}
previousImages := append([]store.PostImage(nil), post.Images...)
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, previousImages)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Body = truncateRunes(body, 12000)
post.Images = images
if err := s.store.UpdatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
@@ -191,7 +217,7 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save post", http.StatusInternalServerError)
return
}
s.publishPostUpdated(post, root, nil)
s.deletePostImageObjects(removedPostImageKeys(previousImages, images))
http.Redirect(
w,
r,
+90
View File
@@ -413,6 +413,12 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
Body: "Water under the cabinet.",
City: "Oakland",
PostDate: pacific.Today(),
Images: []store.PostImage{{
ID: "root-photo", ObjectKey: "post-images/root-photo.jpg",
PublicURL: "https://cdn.example/root-photo.jpg",
Description: "Water pooling below the shutoff valve",
Width: 1200, Height: 900,
}},
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
@@ -421,6 +427,11 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
ParentID: &root.ID,
AuthorID: homeowner.ID,
Body: "The model number is 123.",
Images: []store.PostImage{{
ID: "reply-photo", ObjectKey: "post-images/reply-photo.png",
PublicURL: "https://cdn.example/reply-photo.png",
Width: 900, Height: 1200,
}},
}
if err := mem.CreatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
@@ -429,6 +440,12 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
ParentID: &homeownerReply.ID,
AuthorID: admin.ID,
Body: "Replace the cartridge.",
Images: []store.PostImage{{
ID: "admin-photo", ObjectKey: "post-images/admin-photo.webp",
PublicURL: "https://cdn.example/admin-photo.webp",
Description: "Replacement cartridge orientation",
Width: 1000, Height: 1000,
}},
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
@@ -460,6 +477,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`action="/posts"`,
`data-submit-once`,
`data-submit-button`,
`enctype="multipart/form-data"`,
`data-image-picker`,
`accept="image/jpeg,image/png,image/webp"`,
`aria-live="polite"`,
`name="existing_image_id" value="root-photo"`,
`name="existing_image_id" value="reply-photo"`,
`src="https://cdn.example/root-photo.jpg"`,
`alt="Water pooling below the shutoff valve"`,
`src="https://cdn.example/reply-photo.png"`,
`alt="Photo attached to this post"`,
`src="https://cdn.example/admin-photo.webp"`,
`loading="lazy" decoding="async"`,
`<figcaption>Replacement cartridge orientation</figcaption>`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`href="/questions/` + root.ID + `#post-` + root.ID + `"`,
@@ -475,6 +505,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
if got := strings.Count(body, ">Permalink</a>"); got != 3 {
t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body)
}
if got := strings.Count(body, `data-image-picker`); got != 5 {
t.Fatalf("question page rendered %d image pickers, want 5: %s", got, body)
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body)
}
@@ -490,6 +523,63 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
strings.Contains(rec.Body.String(), `action="/posts/`+root.ID+`/edit"`) {
t.Fatalf("admin edit controls are incorrect: %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil)
for _, cookie := range homeownerCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("hunt page status = %d: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cdn.example") {
t.Fatalf("hunt page rendered post images: %s", rec.Body.String())
}
}
func TestImagePickerAssetsAreServed(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, Config{})
handler := srv.Handler()
for _, asset := range []struct {
path string
wants []string
}{
{
path: "/static/app.js",
wants: []string{
`const pickerSelector = "[data-image-picker]"`,
`new DataTransfer()`,
`addEventListener("drop"`,
`resetImagePicker`,
`URL.revokeObjectURL`,
},
},
{
path: "/static/app.css",
wants: []string{
`.image-dropzone`,
`.image-dropzone:focus-within`,
`.image-preview-list`,
`.post-image-grid`,
`@media (max-width: 520px)`,
},
},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, asset.path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d", asset.path, rec.Code)
}
for _, want := range asset.wants {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("%s missing %q", asset.path, want)
}
}
}
}
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
+26 -8
View File
@@ -18,9 +18,9 @@ import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
@@ -36,8 +36,6 @@ type Config struct {
TrustedProxies []*net.IPNet
Blob blob.Uploader
Mail mail.Notifier
Events events.Publisher
BaseURL string
}
type Server struct {
@@ -106,6 +104,11 @@ type threadPostCtx struct {
Depth int
}
type imagePickerCtx struct {
ID string
Images []store.PostImage
}
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
if cfg.Blob == nil {
cfg.Blob = blob.Disabled{}
@@ -113,9 +116,6 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
if cfg.Events == nil {
cfg.Events = events.Nop{}
}
funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Post: post}
@@ -123,6 +123,12 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
"postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx {
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
},
"imagePicker": func(id string, images []store.PostImage) imagePickerCtx {
return imagePickerCtx{ID: id, Images: images}
},
"newImagePicker": func(id string) imagePickerCtx {
return imagePickerCtx{ID: id}
},
"add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() },
@@ -188,7 +194,7 @@ func (s *Server) Handler() http.Handler {
r.Use(middleware.Recoverer)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 3<<20)
r.Body = http.MaxBytesReader(w, r.Body, requestBodyLimit(r))
next.ServeHTTP(w, r)
})
})
@@ -337,6 +343,11 @@ func (s *Server) handleSubmitForm(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -368,16 +379,23 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
city = truncateRunes(city, 80)
}
post := &store.Post{
ID: uuid.NewString(),
AuthorID: u.ID,
Title: title,
Body: body,
City: city,
}
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Images = images
if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
http.Error(w, "could not save question", http.StatusInternalServerError)
return
}
s.publishPostCreated(post, post, u)
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
}
+5
View File
@@ -173,6 +173,11 @@ func TestRegisterLoginAsk(t *testing.T) {
`id="submit-progress"`,
`data-submit-once`,
`data-submit-button`,
`enctype="multipart/form-data"`,
`data-image-picker`,
`id="submit-images"`,
`accept="image/jpeg,image/png,image/webp"`,
`Add up to 4 JPEG, PNG, or WebP images.`,
} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("submit form missing %q: %s", want, rec.Body.String())
-11
View File
@@ -65,17 +65,6 @@ CREATE TABLE IF NOT EXISTS post_votes (
CREATE INDEX IF NOT EXISTS idx_post_votes_post_id
ON post_votes(post_id);
CREATE TABLE IF NOT EXISTS discord_post_links (
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
discord_message_id TEXT NOT NULL UNIQUE,
discord_thread_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
ON discord_post_links (discord_thread_id)
WHERE discord_thread_id <> '';
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BYTEA NOT NULL,
+245
View File
@@ -749,6 +749,243 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.post-form-actions .btn { flex: 1 1 10rem; }
.image-picker {
min-width: 0;
margin: 10px 0;
padding: 0;
border: 0;
}
.image-picker legend {
margin-bottom: 6px;
padding: 0;
font-family: var(--mono);
font-weight: 500;
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.image-picker-hint {
margin: 0 0 8px;
color: var(--muted);
font-family: var(--mono);
font-size: 0.7rem;
line-height: 1.5;
text-wrap: pretty;
}
.image-dropzone {
position: relative;
min-height: 108px;
display: grid;
place-content: center;
gap: 8px;
padding: 18px 72px 18px 18px;
border: 1px dashed var(--zinc);
border-radius: 3px;
background: #181a1d;
transition: border-color 140ms ease, background-color 140ms ease;
}
.image-dropzone:hover,
.image-dropzone.is-dragging {
border-color: var(--signal);
background: #202124;
}
.image-dropzone:focus-within {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.image-input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.image-dropzone-label {
display: grid;
gap: 3px;
pointer-events: none;
text-align: center;
color: var(--ink);
}
.image-dropzone-label strong {
font-family: var(--sans);
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.image-dropzone-label span {
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.05em;
}
.image-picker-count {
position: absolute;
top: 10px;
right: 10px;
padding: 3px 6px;
border: 1px solid var(--line);
color: var(--muted);
background: var(--panel);
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.06em;
pointer-events: none;
}
.image-picker-error {
margin: 8px 0 0;
color: #ffd0d0;
font-family: var(--mono);
font-size: 0.72rem;
}
.image-preview-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.image-preview {
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
border: 1px solid var(--line);
border-radius: 3px;
overflow: hidden;
background: #141516;
}
.image-preview[hidden] { display: none; }
.image-preview-media {
position: relative;
aspect-ratio: 4 / 3;
background: var(--bg);
overflow: hidden;
}
.image-preview-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview-tag {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 6px;
background: rgba(20, 21, 22, 0.9);
border: 1px solid var(--line);
color: var(--ink);
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.image-preview-fields {
min-width: 0;
display: grid;
align-content: start;
gap: 7px;
padding: 10px;
}
.image-preview-fields label {
display: grid;
gap: 6px;
}
.image-preview-name {
margin: 0;
overflow: hidden;
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-remove {
width: fit-content;
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-decoration: underline;
text-transform: uppercase;
cursor: pointer;
}
.image-remove:hover { color: #ffd0d0; }
.image-remove:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.post-image-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
}
.post-image-grid-1 { grid-template-columns: minmax(0, 1fr); }
.post-image {
min-width: 0;
margin: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 3px;
background: #141516;
}
.post-image img {
width: 100%;
height: 100%;
max-height: 32rem;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.post-image-grid-1 .post-image img {
height: auto;
aspect-ratio: auto;
object-fit: contain;
}
.post-image figcaption {
padding: 8px 10px;
border-top: 1px solid var(--line);
color: var(--muted);
font-family: var(--mono);
font-size: 0.7rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.post-permalink {
display: inline-flex;
align-items: center;
@@ -797,8 +1034,16 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.panel-wrap { padding: 32px; }
}
@media (max-width: 520px) {
.image-preview-list,
.post-image-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.btn-primary:hover { filter: none; }
.image-dropzone { transition: none; }
.submit-progress-bar {
width: 100%;
animation: none;
+194
View File
@@ -1,5 +1,9 @@
(() => {
const formSelector = "form[data-submit-once]";
const pickerSelector = "[data-image-picker]";
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
const maxImageBytes = 5 * 1024 * 1024;
const pickerStates = new WeakMap();
function progressIndicator() {
return document.getElementById("submit-progress");
@@ -21,6 +25,193 @@
}
}
function existingImageCount(picker) {
return picker.querySelectorAll("[data-existing-image]:not([hidden])").length;
}
function updateImageCount(picker, state) {
const count = existingImageCount(picker) + state.entries.length;
const status = picker.querySelector("[data-image-count]");
if (status) {
status.textContent = `${count} of ${state.max}`;
}
}
function showImageError(picker, message) {
const error = picker.querySelector("[data-image-error]");
if (!error) {
return;
}
error.textContent = message;
error.hidden = !message;
}
function imageFileAllowed(file) {
if (allowedImageTypes.has(file.type)) {
return true;
}
if (file.type) {
return false;
}
return /\.(jpe?g|png|webp)$/i.test(file.name);
}
function sameImageFile(left, right) {
return left.name === right.name &&
left.size === right.size &&
left.lastModified === right.lastModified;
}
function syncImageInput(state) {
const transfer = new DataTransfer();
state.entries.forEach((entry) => transfer.items.add(entry.file));
state.input.files = transfer.files;
}
function removeNewImage(picker, state, entry) {
const index = state.entries.indexOf(entry);
if (index === -1) {
return;
}
state.entries.splice(index, 1);
URL.revokeObjectURL(entry.previewURL);
entry.card.remove();
syncImageInput(state);
showImageError(picker, "");
updateImageCount(picker, state);
}
function addImageFiles(picker, state, files) {
showImageError(picker, "");
const uniqueFiles = files.filter((file) =>
!state.entries.some((entry) => sameImageFile(entry.file, file))
);
const available = state.max - existingImageCount(picker) - state.entries.length;
if (uniqueFiles.length > available) {
showImageError(
picker,
available > 0
? `You can add ${available} more ${available === 1 ? "image" : "images"}.`
: "You already have 4 images selected."
);
syncImageInput(state);
return;
}
for (const file of uniqueFiles) {
if (!imageFileAllowed(file)) {
showImageError(picker, "Images must be JPEG, PNG, or WebP.");
syncImageInput(state);
return;
}
if (file.size > maxImageBytes) {
showImageError(picker, `${file.name} is larger than 5 MB.`);
syncImageInput(state);
return;
}
}
uniqueFiles.forEach((file) => {
const fragment = state.template.content.cloneNode(true);
const card = fragment.querySelector("[data-new-image]");
const preview = fragment.querySelector("[data-image-preview]");
const name = fragment.querySelector("[data-image-name]");
const previewURL = URL.createObjectURL(file);
preview.src = previewURL;
if (name) {
name.textContent = file.name;
}
const entry = { file, card, previewURL };
const removeButton = card.querySelector("[data-remove-image]");
removeButton.setAttribute("aria-label", `Remove selected image: ${file.name}`);
removeButton.addEventListener("click", () => {
removeNewImage(picker, state, entry);
});
state.list.appendChild(fragment);
state.entries.push(entry);
});
syncImageInput(state);
updateImageCount(picker, state);
}
function resetImagePicker(picker) {
const state = pickerStates.get(picker);
if (!state) {
return;
}
state.entries.forEach((entry) => {
URL.revokeObjectURL(entry.previewURL);
entry.card.remove();
});
state.entries = [];
state.input.value = "";
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
card.hidden = false;
card.querySelectorAll("input").forEach((input) => {
input.disabled = false;
});
});
showImageError(picker, "");
updateImageCount(picker, state);
}
function initializeImagePicker(picker) {
if (
pickerStates.has(picker) ||
typeof DataTransfer === "undefined" ||
typeof URL.createObjectURL !== "function"
) {
return;
}
const input = picker.querySelector("[data-image-input]");
const dropzone = picker.querySelector("[data-image-dropzone]");
const list = picker.querySelector("[data-image-list]");
const template = picker.querySelector("[data-image-template]");
if (!input || !dropzone || !list || !template) {
return;
}
const state = {
input,
list,
template,
entries: [],
max: Number.parseInt(picker.dataset.maxImages, 10) || 4,
};
pickerStates.set(picker, state);
updateImageCount(picker, state);
input.addEventListener("change", () => {
addImageFiles(picker, state, Array.from(input.files));
});
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
card.querySelector("[data-remove-image]").addEventListener("click", () => {
card.hidden = true;
card.querySelectorAll("input").forEach((existingInput) => {
existingInput.disabled = true;
});
showImageError(picker, "");
updateImageCount(picker, state);
});
});
["dragenter", "dragover"].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.remove("is-dragging");
});
});
dropzone.addEventListener("drop", (event) => {
addImageFiles(picker, state, Array.from(event.dataTransfer.files));
});
picker.closest("form")?.addEventListener("reset", () => {
window.setTimeout(() => resetImagePicker(picker), 0);
});
}
document.addEventListener("submit", (event) => {
const form = event.target.closest(formSelector);
if (!form) {
@@ -50,9 +241,12 @@
window.addEventListener("pageshow", () => {
document.querySelectorAll(formSelector).forEach(resetForm);
document.querySelectorAll(pickerSelector).forEach(resetImagePicker);
const progress = progressIndicator();
if (progress) {
progress.hidden = true;
}
});
document.querySelectorAll(pickerSelector).forEach(initializeImagePicker);
})();
+85
View File
@@ -0,0 +1,85 @@
{{define "imagePicker"}}
<fieldset class="image-picker" data-image-picker data-max-images="4">
<legend>Photos <span class="optional">(optional)</span></legend>
<p id="{{.ID}}-hint" class="image-picker-hint">
Add up to 4 JPEG, PNG, or WebP images. Each image can be up to 5 MB.
</p>
<div class="image-dropzone" data-image-dropzone>
<input id="{{.ID}}" class="image-input" type="file" name="images"
accept="image/jpeg,image/png,image/webp" multiple
aria-describedby="{{.ID}}-hint {{.ID}}-status {{.ID}}-error"
data-image-input>
<label class="image-dropzone-label" for="{{.ID}}">
<strong>Drop photos here</strong>
<span>or click to browse</span>
</label>
<span id="{{.ID}}-status" class="image-picker-count" role="status"
aria-live="polite" data-image-count>{{len .Images}} of 4</span>
</div>
<p id="{{.ID}}-error" class="image-picker-error" role="alert"
data-image-error hidden></p>
<div class="image-preview-list" data-image-list>
{{range .Images}}
<article class="image-preview" data-image-card data-existing-image>
<div class="image-preview-media">
<img src="{{.PublicURL}}" alt="" width="{{.Width}}" height="{{.Height}}">
<span class="image-preview-tag">Saved</span>
</div>
<div class="image-preview-fields">
<input type="hidden" name="existing_image_id" value="{{.ID}}">
<label for="{{$.ID}}-description-{{.ID}}">
Image description <span class="optional">(optional)</span>
</label>
<input id="{{$.ID}}-description-{{.ID}}" type="text"
name="existing_image_description" maxlength="500"
value="{{.Description}}" placeholder="What should people notice?">
<button class="image-remove" type="button" data-remove-image
aria-label="Remove image{{if .Description}}: {{.Description}}{{end}}">
Remove
</button>
</div>
</article>
{{end}}
</div>
<template data-image-template>
<article class="image-preview" data-image-card data-new-image>
<div class="image-preview-media">
<img alt="" data-image-preview>
<span class="image-preview-tag">New</span>
</div>
<div class="image-preview-fields">
<p class="image-preview-name" data-image-name></p>
<label>
Image description <span class="optional">(optional)</span>
<input type="text" name="image_description" maxlength="500"
placeholder="What should people notice?">
</label>
<button class="image-remove" type="button" data-remove-image
aria-label="Remove selected image">
Remove
</button>
</div>
</article>
</template>
<noscript>
<p class="image-picker-hint">Image previews and removal while editing require JavaScript.</p>
</noscript>
</fieldset>
{{end}}
{{define "postImages"}}
{{if .Images}}
<div class="post-image-grid post-image-grid-{{len .Images}}">
{{range .Images}}
<figure class="post-image">
<img src="{{.PublicURL}}" width="{{.Width}}" height="{{.Height}}"
alt="{{if .Description}}{{.Description}}{{else}}Photo attached to this post{{end}}"
loading="lazy" decoding="async">
{{if .Description}}<figcaption>{{.Description}}</figcaption>{{end}}
</figure>
{{end}}
</div>
{{end}}
{{end}}
+8 -3
View File
@@ -3,12 +3,13 @@
{{if canReply .User .Root}}
<details class="post-composer">
<summary>Reply</summary>
<form class="post-form" method="post" action="/posts"
<form class="post-form" method="post" action="/posts" enctype="multipart/form-data"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="parent_id" value="{{.Post.ID}}">
<label for="reply-{{.Post.ID}}">Reply to {{.Post.AuthorName}}</label>
<textarea id="reply-{{.Post.ID}}" name="body" rows="5" required maxlength="12000"></textarea>
{{template "imagePicker" (newImagePicker (printf "reply-images-%s" .Post.ID))}}
<div class="post-form-actions">
<button type="submit" class="btn btn-primary" data-submit-button>Post reply</button>
<button type="reset" class="btn btn-ghost"
@@ -20,13 +21,16 @@
{{if canEditPost .User .Post}}
<details class="post-composer">
<summary>Edit</summary>
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit">
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit"
enctype="multipart/form-data"
data-submit-once data-submitting-label="Saving…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="edit-{{.Post.ID}}">Edit post</label>
<textarea id="edit-{{.Post.ID}}" name="body" rows="5" required
maxlength="12000">{{.Post.Body}}</textarea>
{{template "imagePicker" (imagePicker (printf "edit-images-%s" .Post.ID) .Post.Images)}}
<div class="post-form-actions">
<button type="submit" class="btn btn-primary">Save changes</button>
<button type="submit" class="btn btn-primary" data-submit-button>Save changes</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
@@ -59,6 +63,7 @@
</p>
</header>
<p class="post-body">{{.Post.Body}}</p>
{{template "postImages" .Post}}
{{template "postActions" .}}
{{if .Post.Replies}}
<div class="post-replies">
+1
View File
@@ -17,6 +17,7 @@
{{if isEdited .Question}}<span class="edited">Edited</span>{{end}}
</p>
<p class="post-body">{{.Question.Body}}</p>
{{template "postImages" .Question}}
{{template "postActions" (postCtx .User .CSRF .Question .Question 0)}}
</div>
</article>
+2 -1
View File
@@ -4,7 +4,7 @@
<h1>Ask a question</h1>
<p class="lede">It lands on todays hunt (Pacific time). People vote; the ranking resets at midnight PT.</p>
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
<form class="ask" method="post" action="/submit"
<form class="ask" method="post" action="/submit" enctype="multipart/form-data"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="title">Title</label>
@@ -13,6 +13,7 @@
<textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea>
<label for="city">City <span class="optional">(optional)</span></label>
<input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland">
{{template "imagePicker" (newImagePicker "submit-images")}}
<button type="submit" class="btn btn-primary" data-submit-button>Submit to todays hunt</button>
</form>
</main>