Compare commits

..
Author SHA1 Message Date
codegirl007 04f1010f27 Add post image upload backend (#11)
Adds bounded JPEG, PNG, and WebP processing, route-specific multipart limits, attachment mutation handling, and object cleanup on failed persistence or successful removal.

Reviewed-on: #11
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 08:00:49 +00:00
codegirl007 1840a662d9 Add post image storage
CI / test (pull_request) Successful in 6m17s
2026-08-27 23:46:06 -07:00
44 changed files with 118 additions and 3639 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
+3 -15
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,16 +34,7 @@ func main() {
if err != nil {
log.Fatalf("mail: %v", err)
}
bus := events.New()
mail.Subscribe(bus, store.NewPostgres(db), notifier)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db))
if err != nil {
log.Fatalf("discord: %v", err)
}
if bot != nil {
defer bot.Close()
}
handler := newHandler(db, sessions, uploader, bus)
handler := newHandler(db, sessions, uploader, notifier)
run(&http.Server{
Addr: listenAddr(),
Handler: handler,
@@ -69,14 +58,13 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions
}
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, 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,
Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
Mail: notifier,
})
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;
+3 -4
View File
@@ -63,7 +63,7 @@ WHERE id = sqlc.arg(id);
-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
id, post_id, object_key, public_url, description, position, width, height, created_at
)
VALUES (
sqlc.arg(id),
@@ -71,7 +71,6 @@ VALUES (
sqlc.arg(object_key),
sqlc.arg(public_url),
sqlc.arg(description),
sqlc.arg(kind),
sqlc.arg(position),
sqlc.arg(width),
sqlc.arg(height),
@@ -84,7 +83,7 @@ WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = sqlc.arg(post_id)
ORDER BY position;
@@ -103,7 +102,7 @@ WITH RECURSIVE thread AS (
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.kind, images.position, images.width, images.height, images.created_at
images.description, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position;
+2 -5
View File
@@ -7,13 +7,10 @@ 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/aws/smithy-go v1.27.8
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
@@ -28,11 +25,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect
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/gorilla/websocket v1.4.2 // indirect
github.com/aws/smithy-go v1.27.8 // 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
)
-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=
@@ -56,22 +52,14 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
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=
-15
View File
@@ -8,11 +8,9 @@ import (
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go/middleware"
)
// Uploader stores public avatar objects.
@@ -83,23 +81,10 @@ func NewSpaces(cfg SpacesConfig) Uploader {
Region: cfg.Region,
Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""),
BaseEndpoint: aws.String(cfg.Endpoint),
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
APIOptions: []func(*middleware.Stack) error{
spacesUnsignedPayload,
},
})
return &spaces{client: client, cfg: cfg}
}
// spacesUnsignedPayload signs Spaces PUTs as UNSIGNED-PAYLOAD so the client
// can stream the body without hashing it first.
func spacesUnsignedPayload(stack *middleware.Stack) error {
if err := v4.SwapComputePayloadSHA256ForUnsignedPayloadMiddleware(stack); err != nil {
return v4.AddUnsignedPayloadMiddleware(stack)
}
return nil
}
func (s *spaces) Enabled() bool { return true }
func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
-106
View File
@@ -1,106 +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, 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, name string) (string, error) {
thread, err := s.session.ThreadStartComplex(channelID, &discordgo.ThreadStart{
Name: name,
Type: discordgo.ChannelTypeGuildPublicThread,
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 {
content := messageContent(msg)
embeds := toEmbeds(msg)
_, err := s.session.ChannelMessageEditComplex(&discordgo.MessageEdit{
ID: messageID,
Channel: channelID,
Content: &content,
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{
Content: messageContent(msg),
Embeds: toEmbeds(msg),
AllowedMentions: &discordgo.MessageAllowedMentions{},
}
}
func toEmbeds(msg Message) []*discordgo.MessageEmbed {
main := &discordgo.MessageEmbed{
Title: msg.Title,
URL: publicURL(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
}
-202
View File
@@ -1,202 +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
store store.Store
bus events.Publisher
admins map[string]string
botUserID string
}
// 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, st store.Store) (*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
}
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
bot := New(channelID, links, &sessionAPI{session: session})
bot.store = st
bot.bus = bus
bot.admins = parseAdminMap(os.Getenv("DISCORD_ADMIN_MAP"))
session.AddHandler(bot.onMessageCreate)
if bus != nil {
bus.Subscribe(bot.Handle)
}
if err := session.Open(); err != nil {
_ = session.Close()
return nil, fmt.Errorf("discord gateway: %w", err)
}
if session.State != nil && session.State.User != nil {
bot.botUserID = session.State.User.ID
}
log.Printf("discord: 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) {
_, err := b.links.GetByPostID(ctx, ev.PostID)
if err == nil {
return
}
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: load link %s: %v", ev.PostID, err)
return
}
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)
threadID, err := b.api.StartThread(ctx, b.channelID, msg.ThreadName)
if err != nil {
log.Printf("discord: start thread %s: %v", ev.PostID, err)
return
}
messageID, err := b.api.SendToThread(ctx, threadID, msg)
if err != nil {
log.Printf("discord: send root %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 link.ThreadID, 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
}
-291
View File
@@ -1,291 +0,0 @@
package discord
import (
"context"
"strconv"
"strings"
"sync"
"testing"
"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, name string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.next++
id := "thread-" + strconv.Itoa(f.next)
f.sends = append(f.sends, recordedSend{
Kind: "thread",
ChannelID: channelID,
Name: name,
Msg: Message{ThreadName: name},
})
return id, 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 != "thread" ||
api.sends[1].Kind != "thread-msg" {
t.Fatalf("root sends = %+v", api.sends)
}
if api.sends[0].ChannelID != "channel-1" ||
api.sends[0].Name != "sam asks: Leaky sink" ||
api.sends[1].ChannelID != "thread-1" {
t.Fatalf("root routing = %+v", api.sends)
}
if got := api.sends[1].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-2" || link.ThreadID != "thread-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-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 != "thread-1" || api.edits[0].Name != "msg-2" {
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-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 TestOutboundSkipsAlreadyLinkedPost(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "reply-1",
MessageID: "d-reply-1",
}); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "root-1",
MessageID: "d-root",
ThreadID: "thread-1",
}); err != nil {
t.Fatal(err)
}
bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: "reply-1",
RootID: "root-1",
ParentID: "root-1",
Body: "Already on Discord.",
}})
if len(api.sends) != 0 {
t.Fatalf("echoed already-linked reply: %+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 != "sam asks: 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 != "Someone asks: Question" {
t.Fatalf("reply format = %+v", reply)
}
content := messageContent(got)
if strings.Contains(content, "Leaky sink") ||
!strings.Contains(content, "It drips.") ||
!strings.Contains(content, "Oakland") {
t.Fatalf("content = %q", content)
}
if publicURL("/questions/x") != "" || publicURL("http://localhost:8080/q") != "" {
t.Fatal("localhost or relative permalink should not be an embed URL")
}
}
func TestFromEnvDisabled(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "")
bot, err := FromEnv(newMemoryLinks(), nil, 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, 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, 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)
}
}
-111
View File
@@ -1,111 +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(author, 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(author, title string) string {
author = strings.TrimSpace(author)
if author == "" {
author = "Someone"
}
title = strings.TrimSpace(title)
if title == "" {
title = "Question"
}
return truncateRunes(author+" asks: "+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) == ""
}
func messageContent(msg Message) string {
var parts []string
if body := strings.TrimSpace(msg.Description); body != "" {
parts = append(parts, body)
}
var meta []string
if msg.City != "" {
meta = append(meta, msg.City)
}
if msg.Author != "" {
meta = append(meta, msg.Author)
}
if len(meta) > 0 {
parts = append(parts, strings.Join(meta, " · "))
}
if u := publicURL(msg.URL); u != "" {
parts = append(parts, u)
}
return truncateRunes(strings.Join(parts, "\n"), 2000)
}
func publicURL(raw string) string {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, "https://") {
return ""
}
if strings.Contains(raw, "localhost") || strings.Contains(raw, "127.0.0.1") {
return ""
}
return raw
}
-203
View File
@@ -1,203 +0,0 @@
package discord
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"strings"
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/store"
)
const inboundBodyLimit = 12000
type inboundMessage struct {
ID string
ChannelID string
GuildID string
AuthorID string
Content string
ReferencedMessageID string
Bot bool
Attachments int
}
func parseAdminMap(raw string) map[string]string {
out := map[string]string{}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, username, ok := strings.Cut(part, ":")
id = strings.TrimSpace(id)
username = store.NormalizeUsername(username)
if !ok || id == "" || username == "" {
log.Printf("discord: skip invalid DISCORD_ADMIN_MAP entry %q", part)
continue
}
out[id] = username
}
return out
}
func (b *Bot) onMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) {
if b == nil || m == nil || m.Author == nil {
return
}
in := inboundMessage{
ID: m.ID,
ChannelID: m.ChannelID,
GuildID: m.GuildID,
AuthorID: m.Author.ID,
Content: m.Content,
Bot: m.Author.Bot,
Attachments: len(m.Attachments),
}
if m.MessageReference != nil {
in.ReferencedMessageID = m.MessageReference.MessageID
}
b.handleInbound(in)
}
func (b *Bot) handleInbound(in inboundMessage) {
if b == nil || b.store == nil {
return
}
if in.Bot || strings.TrimSpace(in.GuildID) == "" {
return
}
if b.botUserID != "" && in.AuthorID == b.botUserID {
return
}
body := strings.TrimSpace(in.Content)
if in.Attachments > 0 {
log.Printf("discord: ignoring %d attachment(s) on %s", in.Attachments, in.ID)
}
if body == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), discordTimeout)
defer cancel()
if !b.knownChannel(ctx, in.ChannelID) {
return
}
username := b.admins[in.AuthorID]
if username == "" {
return
}
author, err := b.store.UserByUsername(ctx, username)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: inbound author %s: %v", username, err)
}
return
}
if !author.Admin() {
log.Printf("discord: inbound %s is not an admin", username)
return
}
parent, root, err := b.inboundParent(ctx, in)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: inbound parent %s: %v", in.ID, err)
}
return
}
if root.PostState == store.PostStateHidden {
return
}
reply := &store.Post{
AuthorID: author.ID,
Body: truncateRunes(body, inboundBodyLimit),
ParentID: &parent.ID,
}
if err := b.store.CreatePost(ctx, reply); err != nil {
log.Printf("discord: create inbound %s: %v", in.ID, err)
return
}
if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: reply.ID,
MessageID: in.ID,
}); err != nil {
log.Printf("discord: save inbound link %s: %v", reply.ID, err)
return
}
b.publishInbound(reply, root, author)
log.Printf("discord: inbound reply %s -> post %s", in.ID, reply.ID)
}
func (b *Bot) knownChannel(ctx context.Context, channelID string) bool {
if strings.TrimSpace(channelID) == "" {
return false
}
if channelID == b.channelID {
return true
}
_, err := b.links.GetRootByThreadID(ctx, channelID)
return err == nil
}
func (b *Bot) inboundParent(ctx context.Context, in inboundMessage) (*store.Post, *store.Post, error) {
if ref := strings.TrimSpace(in.ReferencedMessageID); ref != "" {
link, err := b.links.GetByMessageID(ctx, ref)
if err == nil {
return b.postAndRoot(ctx, link.PostID)
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, nil, err
}
}
link, err := b.links.GetRootByThreadID(ctx, in.ChannelID)
if err != nil {
return nil, nil, err
}
return b.postAndRoot(ctx, link.PostID)
}
func (b *Bot) postAndRoot(ctx context.Context, postID string) (*store.Post, *store.Post, error) {
postID = strings.TrimSpace(postID)
if postID == "" {
return nil, nil, sql.ErrNoRows
}
post, err := b.store.GetPost(ctx, postID)
if err != nil {
return nil, nil, err
}
current := post
seen := map[string]bool{}
for current.ParentID != nil {
if seen[current.ID] {
return nil, nil, fmt.Errorf("post ancestry cycle at %s", current.ID)
}
seen[current.ID] = true
current, err = b.store.GetPost(ctx, *current.ParentID)
if err != nil {
return nil, nil, err
}
}
return post, current, nil
}
func (b *Bot) publishInbound(reply, root *store.Post, author *store.User) {
if b == nil || b.bus == nil || reply == nil || root == nil || author == nil {
return
}
ev := events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
Body: reply.Body,
AuthorID: author.ID,
AuthorName: author.Name,
AuthorRole: string(author.Role),
}
if reply.ParentID != nil {
ev.ParentID = *reply.ParentID
}
b.bus.Publish(context.Background(), events.PostCreated{PostEvent: ev})
}
-219
View File
@@ -1,219 +0,0 @@
package discord
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestParseAdminMap(t *testing.T) {
t.Parallel()
got := parseAdminMap(" 123:Plumber ,456:other,bad, :empty,789: ")
if got["123"] != "plumber" || got["456"] != "other" || len(got) != 2 {
t.Fatalf("parseAdminMap = %#v", got)
}
}
func TestInboundCreatesSiteReply(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
mailer := &mail.Recording{}
bot := inboundTestBot(mem, links, api, admin.Username)
bus := events.New()
defer bus.Close()
bot.bus = bus
mail.Subscribe(bus, mem, mailer)
bus.Subscribe(bot.Handle)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
bot.handleInbound(inboundMessage{
ID: "d-reply-1",
ChannelID: "thread-1",
GuildID: "guild-1",
AuthorID: "snow-admin",
Content: "Replace the cartridge.",
})
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 reply.AuthorID != admin.ID || reply.Body != "Replace the cartridge." || reply.ParentID == nil || *reply.ParentID != root.ID {
t.Fatalf("reply = %+v", reply)
}
link, err := links.GetByPostID(context.Background(), reply.ID)
if err != nil || link.MessageID != "d-reply-1" || link.ThreadID != "" {
t.Fatalf("inbound link = %+v, %v", link, err)
}
time.Sleep(20 * time.Millisecond)
if len(api.sends) != 0 || len(api.edits) != 0 {
t.Fatalf("inbound echoed to Discord: sends=%+v edits=%+v", api.sends, api.edits)
}
msgs := waitForMail(t, mailer, 1)
if msgs[0].ToEmail != homeowner.Email || msgs[0].ReplyID != reply.ID || msgs[0].RootID != root.ID {
t.Fatalf("mail = %+v", msgs[0])
}
}
func TestInboundParentsFromReference(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
bot := inboundTestBot(mem, links, &fakeAPI{}, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
plumberReply := &store.Post{ParentID: &root.ID, AuthorID: admin.ID, Body: "First look."}
if err := mem.CreatePost(context.Background(), plumberReply); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: plumberReply.ID,
MessageID: "d-plumber-1",
}); err != nil {
t.Fatal(err)
}
bot.handleInbound(inboundMessage{
ID: "d-nested",
ChannelID: "thread-1",
GuildID: "guild-1",
AuthorID: "snow-admin",
Content: "More detail.",
ReferencedMessageID: "d-plumber-1",
})
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 || len(thread.Replies[0].Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
nested := thread.Replies[0].Replies[0]
if nested.ParentID == nil || *nested.ParentID != plumberReply.ID {
t.Fatalf("nested parent = %+v", nested)
}
}
func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
t.Parallel()
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
bot := inboundTestBot(mem, links, api, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
hidden := &store.Post{
AuthorID: homeowner.ID,
Title: "Hidden",
Body: "No.",
PostDate: pacific.Today(),
PostState: store.PostStateHidden,
}
if err := mem.CreatePost(context.Background(), hidden); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: hidden.ID,
MessageID: "d-hidden",
ThreadID: "thread-hidden",
}); err != nil {
t.Fatal(err)
}
cases := []inboundMessage{
{ID: "bot", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: "x", Bot: true},
{ID: "dm", ChannelID: "thread-1", AuthorID: "snow-admin", Content: "x"},
{ID: "self", ChannelID: "thread-1", GuildID: "g", AuthorID: "bot-1", Content: "x"},
{ID: "stranger", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-other", Content: "x"},
{ID: "elsewhere", ChannelID: "other-thread", GuildID: "g", AuthorID: "snow-admin", Content: "x"},
{ID: "empty", ChannelID: "thread-1", GuildID: "g", AuthorID: "snow-admin", Content: " ", Attachments: 1},
{ID: "hidden", ChannelID: "thread-hidden", GuildID: "g", AuthorID: "snow-admin", Content: "x"},
{ID: "channel-root", ChannelID: "channel-1", GuildID: "g", AuthorID: "snow-admin", Content: "new question"},
}
for _, in := range cases {
bot.handleInbound(in)
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 0 {
t.Fatalf("unexpected replies: %+v, %v", thread, err)
}
if len(api.sends) != 0 {
t.Fatalf("unexpected discord sends %+v", api.sends)
}
}
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, adminUsername string) *Bot {
bot := New("channel-1", links, api)
bot.store = mem
bot.admins = map[string]string{"snow-admin": adminUsername}
bot.botUserID = "bot-1"
return bot
}
func seedInboundUsers(t *testing.T) (*store.Memory, *store.User, *store.User) {
t.Helper()
mem := store.NewMemory()
homeowner := &store.User{
Username: "homeowner",
Name: "Sam",
Email: "sam@example.com",
PasswordHash: "x",
Role: store.RoleUser,
}
if err := mem.CreateUser(context.Background(), homeowner); err != nil {
t.Fatal(err)
}
admin := &store.User{
Username: "plumber",
Name: "Pat",
Email: "pat@example.com",
PasswordHash: "x",
Role: store.RoleAdmin,
}
if err := mem.CreateUser(context.Background(), admin); err != nil {
t.Fatal(err)
}
return mem, homeowner, admin
}
func seedLinkedRoot(t *testing.T, mem *store.Memory, links *memoryLinks, authorID, threadID string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: "Leaky sink",
Body: "It drips.",
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: root.ID,
MessageID: "d-root",
ThreadID: threadID,
}); err != nil {
t.Fatal(err)
}
return root
}
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
-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
}
-89
View File
@@ -1,89 +0,0 @@
package mail
import (
"context"
"log"
"strings"
"time"
"plumber/internal/events"
"plumber/internal/store"
)
// Subscribe sends reply emails from PostCreated events. Nop or nil is a no-op.
func Subscribe(bus *events.Bus, st store.Store, n Notifier) {
if bus == nil || st == nil || n == nil {
return
}
if _, disabled := n.(Nop); disabled {
return
}
s := subscriber{store: st, mail: n}
bus.Subscribe(s.handle)
}
type subscriber struct {
store store.Store
mail Notifier
}
func (s subscriber) handle(_ context.Context, ev any) {
created, ok := ev.(events.PostCreated)
if !ok {
return
}
if strings.TrimSpace(created.ParentID) == "" {
return
}
go s.notifyReply(created.PostEvent)
}
func (s subscriber) notifyReply(ev events.PostEvent) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
parent, err := s.store.GetPost(ctx, ev.ParentID)
if err != nil {
log.Printf("notify reply %s: load parent: %v", ev.PostID, err)
return
}
root, err := s.store.GetPost(ctx, ev.RootID)
if err != nil {
log.Printf("notify reply %s: load root: %v", ev.PostID, err)
return
}
author, err := s.store.UserByID(ctx, ev.AuthorID)
if err != nil {
log.Printf("notify reply %s: load author: %v", ev.PostID, err)
return
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: ev.PostID,
ReplyBody: ev.Body,
ReplyAuthorName: author.Name,
}
recipient, err := s.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := s.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}
-200
View File
@@ -1,200 +0,0 @@
package mail
import (
"context"
"testing"
"time"
"plumber/internal/events"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestSubscribeReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
adminReply := seedMailReply(t, mem, admin.ID, root.ID)
homeownerReply := seedMailReply(t, mem, homeowner.ID, adminReply.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, recording)
ctx := context.Background()
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: root.ID,
RootID: root.ID,
Title: root.Title,
Body: root.Body,
AuthorID: homeowner.ID,
}})
bus.Publish(ctx, events.PostUpdated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: "Edited",
AuthorID: admin.ID,
}})
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: adminReply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: adminReply.Body,
AuthorID: admin.ID,
}})
msgs := waitForMail(t, recording, 1)
if msg := msgs[0]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.RootTitle != root.Title ||
msg.ReplyID != adminReply.ID ||
msg.ReplyBody != adminReply.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("admin reply = %+v", msg)
}
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: homeownerReply.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: homeownerReply.Body,
AuthorID: homeowner.ID,
}})
msgs = waitForMail(t, recording, 2)
if msg := msgs[1]; msg.ToEmail != admin.Email ||
msg.RootID != root.ID ||
msg.ReplyID != homeownerReply.ID ||
msg.ReplyAuthorName != homeowner.Name {
t.Fatalf("homeowner reply = %+v", msg)
}
nestedAdmin := seedMailReply(t, mem, admin.ID, adminReply.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: nestedAdmin.ID,
RootID: root.ID,
ParentID: adminReply.ID,
Body: nestedAdmin.Body,
AuthorID: admin.ID,
}})
msgs = waitForMail(t, recording, 3)
if msg := msgs[2]; msg.ToEmail != homeowner.Email ||
msg.RootID != root.ID ||
msg.ReplyBody != nestedAdmin.Body ||
msg.ReplyAuthorName != admin.Name {
t.Fatalf("nested admin reply = %+v", msg)
}
self := seedMailReply(t, mem, homeowner.ID, root.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: self.ID,
RootID: root.ID,
ParentID: root.ID,
Body: self.Body,
AuthorID: homeowner.ID,
}})
noEmail := seedMailUser(t, mem, "quiet", store.RoleUser, "")
quietRoot := seedMailRoot(t, mem, noEmail.ID, "Quiet thread", "No email.")
quietReply := seedMailReply(t, mem, admin.ID, quietRoot.ID)
bus.Publish(ctx, events.PostCreated{PostEvent: events.PostEvent{
PostID: quietReply.ID,
RootID: quietRoot.ID,
ParentID: quietRoot.ID,
Body: quietReply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 3 {
t.Fatalf("self, root, edit, or no-email sent mail: %+v", recording.Snapshot())
}
}
func TestSubscribeNopIgnoresReplies(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
homeowner := seedMailUser(t, mem, "homeowner", store.RoleUser, "sam@example.com")
admin := seedMailUser(t, mem, "plumber", store.RoleAdmin, "pat@example.com")
root := seedMailRoot(t, mem, homeowner.ID, "Leaky sink", "It drips.")
reply := seedMailReply(t, mem, admin.ID, root.ID)
bus := events.New()
defer bus.Close()
recording := &Recording{}
Subscribe(bus, mem, Nop{})
Subscribe(nil, mem, recording)
Subscribe(bus, mem, nil)
bus.Publish(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
ParentID: root.ID,
Body: reply.Body,
AuthorID: admin.ID,
}})
time.Sleep(50 * time.Millisecond)
if recording.Len() != 0 {
t.Fatalf("Nop or nil subscribe sent mail: %+v", recording.Snapshot())
}
}
func seedMailUser(t *testing.T, mem *store.Memory, username string, role store.Role, email string) *store.User {
t.Helper()
u := &store.User{
Username: username,
Name: username,
Email: email,
PasswordHash: "x",
Role: role,
}
if err := mem.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
return u
}
func seedMailRoot(t *testing.T, mem *store.Memory, authorID, title, body string) *store.Post {
t.Helper()
root := &store.Post{
AuthorID: authorID,
Title: title,
Body: body,
PostDate: pacific.Today(),
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
}
return root
}
func seedMailReply(t *testing.T, mem *store.Memory, authorID, parentID string) *store.Post {
t.Helper()
reply := &store.Post{
ParentID: &parentID,
AuthorID: authorID,
Body: "Reply from " + authorID,
}
if err := mem.CreatePost(context.Background(), reply); err != nil {
t.Fatal(err)
}
return reply
}
func waitForMail(t *testing.T, recording *Recording, want int) []PostReply {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if recording.Len() >= want {
return recording.Snapshot()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("recorded %d notifications, want %d", recording.Len(), want)
return nil
}
-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,
}
}
-47
View File
@@ -139,51 +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 migratePostImageVideo(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"add kind", `ALTER TABLE post_images ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'image'`},
{"drop kind check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_kind_check`},
{"add kind check", `ALTER TABLE post_images ADD CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video'))`},
{"drop width check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_width_check`},
{"drop height check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_height_check`},
{"add width check", `ALTER TABLE post_images ADD CONSTRAINT post_images_width_check CHECK (width >= 0)`},
{"add height check", `ALTER TABLE post_images ADD CONSTRAINT post_images_height_check CHECK (height >= 0)`},
{"drop image dims check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_image_dims_check`},
{"add image dims check", `ALTER TABLE post_images ADD CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0))`},
{"drop position check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_position_check`},
{"add position check", `ALTER TABLE post_images ADD CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4)`},
{"one video index", `CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx ON post_images (post_id) WHERE kind = 'video'`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -376,8 +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},
{"012_post_image_video", migratePostImageVideo},
}
for _, m := range migrations {
if applied[m.version] {
+5 -67
View File
@@ -81,18 +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 := migratePostImageVideo(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostImageVideo(ctx, conn); err != nil {
t.Fatalf("post image video 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');
@@ -124,9 +112,9 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
}
imageQueries := sqlc.New(conn)
for _, image := range []sqlc.CreatePostImageParams{
{ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Kind: "image", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Kind: "image", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Kind: "image", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"},
{ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"},
} {
if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err)
@@ -150,60 +138,10 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg",
PublicUrl: "https://cdn.example/root-5.jpg", Kind: "image", Position: 5,
PublicUrl: "https://cdn.example/root-5.jpg", Position: 4,
Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("position 5 unexpectedly succeeded")
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "root-video-1", PostID: "root-1", ObjectKey: "posts/root-1/clip.mp4",
PublicUrl: "https://cdn.example/clip.mp4", Kind: "video", Position: 2,
Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z",
}); err != nil {
t.Fatal(err)
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "root-video-2", PostID: "root-1", ObjectKey: "posts/root-1/clip-2.mp4",
PublicUrl: "https://cdn.example/clip-2.mp4", Kind: "video", Position: 3,
Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("second video 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)
t.Fatal("fifth image position unexpectedly succeeded")
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
+3 -32
View File
@@ -29,20 +29,16 @@ const (
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
MaxPostImages = 4
MaxPostVideos = 1
MaxImageDescriptionRunes = 500
MediaKindImage = "image"
MediaKindVideo = "video"
)
// PostImage is one ordered public image or video attached to a post.
// PostImage is one ordered public image attached to a post.
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicURL string
Description string
Kind string
Position int
Width int
Height int
@@ -204,13 +200,12 @@ func preparePost(p *Post) error {
}
func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages+MaxPostVideos {
return fmt.Errorf("%w: at most %d images and %d video are allowed", ErrInvalidPost, MaxPostImages, MaxPostVideos)
if len(p.Images) > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano)
images, videos := 0, 0
for i := range p.Images {
image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID)
@@ -218,13 +213,6 @@ func preparePostImages(p *Post) error {
image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description)
image.Kind = strings.TrimSpace(image.Kind)
if image.Kind == "" {
image.Kind = MediaKindImage
}
if image.Kind != MediaKindImage && image.Kind != MediaKindVideo {
return fmt.Errorf("%w: invalid media kind", ErrInvalidPost)
}
if image.ID == "" {
image.ID = uuid.NewString()
}
@@ -240,19 +228,10 @@ func preparePostImages(p *Post) error {
if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
}
if image.Kind == MediaKindImage {
images++
if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost)
}
} else {
videos++
if image.Width < 0 || image.Height < 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid video dimensions", ErrInvalidPost)
}
}
if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
}
@@ -263,12 +242,6 @@ func preparePostImages(p *Post) error {
image.CreatedAt = now
}
}
if images > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
if videos > MaxPostVideos {
return fmt.Errorf("%w: at most %d video is allowed", ErrInvalidPost, MaxPostVideos)
}
return nil
}
@@ -280,7 +253,6 @@ func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage)
ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL,
Description: image.Description,
Kind: image.Kind,
Position: int16(image.Position),
Width: int32(image.Width),
Height: int32(image.Height),
@@ -299,7 +271,6 @@ func postImageFromSQL(image sqlc.PostImage) PostImage {
ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl,
Description: image.Description,
Kind: image.Kind,
Position: int(image.Position),
Width: int(image.Width),
Height: int(image.Height),
-30
View File
@@ -338,36 +338,6 @@ func TestMemoryPostImages(t *testing.T) {
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
}
withVideo := &Post{
AuthorID: homeowner.ID,
Title: "With video",
Body: "Four photos and a clip.",
Images: append(validPostImages(4), PostImage{
ID: "clip-1",
ObjectKey: "posts/clip-1.mp4",
PublicURL: "https://cdn.example/clip-1.mp4",
Kind: MediaKindVideo,
}),
}
if err := mem.CreatePost(ctx, withVideo); err != nil {
t.Fatalf("four images and one video: %v", err)
}
if withVideo.Images[4].Kind != MediaKindVideo || withVideo.Images[4].Position != 4 {
t.Fatalf("video not stored: %+v", withVideo.Images[4])
}
twoVideos := &Post{
AuthorID: homeowner.ID,
Title: "Two clips",
Body: "Not allowed.",
Images: []PostImage{
{ObjectKey: "posts/a.mp4", PublicURL: "https://cdn.example/a.mp4", Kind: MediaKindVideo},
{ObjectKey: "posts/b.mp4", PublicURL: "https://cdn.example/b.mp4", Kind: MediaKindVideo},
},
}
if err := mem.CreatePost(ctx, twoVideos); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("two-video create error = %v, want ErrInvalidPost", err)
}
}
func validPostImages(count int) []PostImage {
-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
}
-8
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
@@ -35,7 +28,6 @@ type PostImage struct {
ObjectKey string
PublicUrl string
Description string
Kind string
Position int16
Width int32
Height int32
+4 -9
View File
@@ -59,7 +59,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
id, post_id, object_key, public_url, description, position, width, height, created_at
)
VALUES (
$1,
@@ -70,8 +70,7 @@ VALUES (
$6,
$7,
$8,
$9,
$10
$9
)
`
@@ -81,7 +80,6 @@ type CreatePostImageParams struct {
ObjectKey string
PublicUrl string
Description string
Kind string
Position int16
Width int32
Height int32
@@ -95,7 +93,6 @@ func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams
arg.ObjectKey,
arg.PublicUrl,
arg.Description,
arg.Kind,
arg.Position,
arg.Width,
arg.Height,
@@ -204,7 +201,7 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
const listPostImages = `-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = $1
ORDER BY position
@@ -225,7 +222,6 @@ func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImag
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Kind,
&i.Position,
&i.Width,
&i.Height,
@@ -331,7 +327,7 @@ WITH RECURSIVE thread AS (
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.kind, images.position, images.width, images.height, images.created_at
images.description, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position
@@ -352,7 +348,6 @@ func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]Po
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Kind,
&i.Position,
&i.Width,
&i.Height,
-68
View File
@@ -1,68 +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 {
if img.Kind == store.MediaKindVideo {
continue
}
ev.Images = append(ev.Images, events.Image{
URL: img.PublicURL,
Description: img.Description,
})
}
}
return ev
}
-256
View File
@@ -1,256 +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)
}
}
func TestPostEventOmitsVideos(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, Config{})
got := srv.postEvent(&store.Post{
ID: "root-1",
Title: "Clip",
Body: "Photo and video.",
Images: []store.PostImage{
{PublicURL: "https://cdn.example/a.jpg", Description: "Still", Kind: store.MediaKindImage},
{PublicURL: "https://cdn.example/a.mp4", Description: "Walkthrough", Kind: store.MediaKindVideo},
},
}, nil, nil)
if len(got.Images) != 1 || got.Images[0].URL != "https://cdn.example/a.jpg" {
t.Fatalf("event images = %+v", got.Images)
}
}
+13 -175
View File
@@ -28,8 +28,7 @@ import (
const (
defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20
postVideoMaxFileBytes = 25 << 20
postImageMaxRequestBytes = 50 << 20 // 4 images + 1 video + form fields
postImageMaxRequestBytes = 22 << 20
postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000
@@ -68,7 +67,7 @@ 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 upload.", http.StatusBadRequest)
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
return func() {}, false
}
if mediaType != "multipart/form-data" {
@@ -98,10 +97,10 @@ func writePostImageRequestError(w http.ResponseWriter, err error) {
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "Upload is too large.", http.StatusRequestEntityTooLarge)
http.Error(w, "Image upload is too large.", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "Could not read upload.", http.StatusBadRequest)
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
}
func (s *Server) postImagesFromForm(
@@ -122,23 +121,9 @@ func (s *Server) postImagesFromForm(
if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
}
kinds := make([]string, len(files))
newImages, newVideos := 0, 0
for i, header := range files {
kinds[i] = sniffPostMedia(header)
if kinds[i] == store.MediaKindVideo {
newVideos++
} else {
newImages++
}
}
retainedImages, retainedVideos := countPostMedia(retained)
if retainedImages+newImages > store.MaxPostImages {
if len(retained)+len(files) > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if retainedVideos+newVideos > store.MaxPostVideos {
return nil, nil, invalidPostImage("You can attach one video.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
@@ -157,68 +142,11 @@ func (s *Server) postImagesFromForm(
s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
item, objectKey, err := s.uploadPostMedia(ctx, postID, header, kinds[i], description)
prepared, err := preparePostImage(header)
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, err
}
newKeys = append(newKeys, objectKey)
images = append(images, item)
}
return images, newKeys, nil
}
func countPostMedia(items []store.PostImage) (images, videos int) {
for _, item := range items {
if item.Kind == store.MediaKindVideo {
videos++
} else {
images++
}
}
return images, videos
}
func (s *Server) uploadPostMedia(
ctx context.Context,
postID string,
header *multipart.FileHeader,
kind, description string,
) (store.PostImage, string, error) {
if kind == store.MediaKindVideo {
prepared, err := preparePostVideo(header)
if err != nil {
return store.PostImage{}, "", err
}
defer prepared.body.Close()
mediaID := uuid.NewString()
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: prepared.body,
ContentType: prepared.contentType,
Size: prepared.size,
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload video. Try again later.",
cause: err,
}
}
return store.PostImage{
ID: mediaID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindVideo,
}, objectKey, nil
}
prepared, err := preparePostImage(header)
if err != nil {
return store.PostImage{}, "", err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
@@ -228,22 +156,25 @@ func (s *Server) uploadPostMedia(
Size: int64(len(prepared.body)),
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
s.deletePostImageObjects(newKeys)
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
return store.PostImage{
newKeys = append(newKeys, objectKey)
images = append(images, store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindImage,
Width: prepared.width,
Height: prepared.height,
}, objectKey, nil
})
}
return images, newKeys, nil
}
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
@@ -367,99 +298,6 @@ func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
return result, nil
}
func sniffPostMedia(header *multipart.FileHeader) string {
if header == nil {
return store.MediaKindImage
}
file, err := header.Open()
if err != nil {
return store.MediaKindImage
}
defer file.Close()
peek := make([]byte, 512)
n, err := io.ReadFull(file, peek)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return store.MediaKindImage
}
return mediaKindFromBytes(peek[:n])
}
func mediaKindFromBytes(raw []byte) string {
switch http.DetectContentType(raw) {
case "video/mp4", "video/webm":
return store.MediaKindVideo
case "image/jpeg", "image/png", "image/webp":
return store.MediaKindImage
}
if isMP4(raw) || isWebM(raw) {
return store.MediaKindVideo
}
return store.MediaKindImage
}
func isMP4(raw []byte) bool {
return len(raw) >= 8 && string(raw[4:8]) == "ftyp"
}
func isWebM(raw []byte) bool {
return len(raw) >= 4 && raw[0] == 0x1a && raw[1] == 0x45 && raw[2] == 0xdf && raw[3] == 0xa3
}
type preparedPostVideo struct {
body io.ReadCloser
size int64
extension string
contentType string
}
func preparePostVideo(header *multipart.FileHeader) (preparedPostVideo, error) {
if header == nil {
return preparedPostVideo{}, invalidPostImage("Select a valid video.", nil)
}
if header.Size == 0 {
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
}
if header.Size > postVideoMaxFileBytes {
return preparedPostVideo{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each video must be 25 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
peek := make([]byte, 512)
n, err := io.ReadFull(file, peek)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
file.Close()
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
if n == 0 {
file.Close()
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
}
switch mediaKindFromBytes(peek[:n]) {
case store.MediaKindVideo:
default:
file.Close()
return preparedPostVideo{}, invalidPostImage("Videos must be MP4 or WebM.", nil)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
file.Close()
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
result := preparedPostVideo{body: file, size: header.Size}
if isWebM(peek[:n]) {
result.extension = ".webm"
result.contentType = "video/webm"
return result, nil
}
result.extension = ".mp4"
result.contentType = "video/mp4"
return result, nil
}
func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil {
+1 -171
View File
@@ -12,7 +12,6 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
@@ -57,47 +56,6 @@ func TestPreparePostImage(t *testing.T) {
}
}
func TestPreparePostVideo(t *testing.T) {
t.Parallel()
mp4 := tinyMP4()
prepared, err := preparePostVideoHeader(t, "clip.mp4", mp4)
if err != nil {
t.Fatal(err)
}
defer prepared.body.Close()
got, err := io.ReadAll(prepared.body)
if err != nil {
t.Fatal(err)
}
if prepared.extension != ".mp4" || prepared.contentType != "video/mp4" ||
prepared.size != int64(len(mp4)) || !bytes.Equal(got, mp4) {
t.Fatalf("prepared MP4 = %+v len(body)=%d", prepared, len(got))
}
webm := tinyWebM()
prepared, err = preparePostVideoHeader(t, "clip.webm", webm)
if err != nil {
t.Fatal(err)
}
defer prepared.body.Close()
if prepared.extension != ".webm" || prepared.contentType != "video/webm" ||
prepared.size != int64(len(webm)) {
t.Fatalf("prepared WebM = %+v", prepared)
}
if _, err := preparePostVideoHeader(t, "empty.mp4", nil); err == nil {
t.Fatal("empty video unexpectedly succeeded")
}
if _, err := preparePostVideoHeader(t, "notes.txt", []byte("not a video")); err == nil {
t.Fatal("text video upload unexpectedly succeeded")
}
_, err = preparePostVideoHeader(t, "too-large.mp4", make([]byte, postVideoMaxFileBytes+1))
var requestErr *postImageRequestError
if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized video error = %v, want 413 request error", err)
}
}
func TestOrientPostImage(t *testing.T) {
t.Parallel()
@@ -245,87 +203,6 @@ func TestPostImageMultipartLifecycle(t *testing.T) {
}
}
func TestPostVideoMultipartLifecycle(t *testing.T) {
t.Parallel()
blobs := &recordingImageBlob{}
srv, mem := newTestServer(t, Config{Blob: blobs})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("video"), "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": {"Valve clip"},
"body": {"A photo and a video."},
"city": {"Oakland"},
"image_description": {"Still", "Walkthrough"},
}, []multipartTestFile{
{name: "still.png", body: solidPNG(t, 40, 20)},
{name: "walk.mp4", body: tinyMP4()},
}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root video 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].Kind != store.MediaKindImage ||
root.Images[1].Kind != store.MediaKindVideo ||
root.Images[1].Description != "Walkthrough" ||
!strings.HasPrefix(root.Images[1].ObjectKey, "post-videos/") {
t.Fatalf("root media = %+v", root.Images)
}
clip := tinyMP4()
var streamed recordedImageUpload
for _, upload := range blobs.recordedUploads() {
if strings.HasPrefix(upload.key, "post-videos/") {
streamed = upload
break
}
}
if streamed.size != int64(len(clip)) || !bytes.Equal(streamed.body, clip) {
t.Fatalf("streamed video upload = %+v", streamed)
}
rec = multipartPost(t, handler, "/posts/"+root.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Keep the clip."},
"existing_image_id": {root.Images[1].ID},
"existing_image_description": {"Kept clip"},
}, nil, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("retain video status = %d: %s", rec.Code, rec.Body.String())
}
edited, err := mem.GetPost(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 1 || edited.Images[0].Kind != store.MediaKindVideo ||
edited.Images[0].Description != "Kept clip" {
t.Fatalf("retained video = %+v", edited.Images)
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Two clips."},
}, []multipartTestFile{
{name: "a.mp4", body: tinyMP4()},
{name: "b.mp4", body: tinyMP4()},
}, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("two-video status = %d, want 400", rec.Code)
}
}
func TestPostImageUploadCompensation(t *testing.T) {
t.Parallel()
@@ -441,12 +318,6 @@ func multipartPost(
return rec
}
func preparePostVideoHeader(t *testing.T, name string, body []byte) (preparedPostVideo, error) {
t.Helper()
header := multipartFileHeader(t, name, body)
return preparePostVideo(header)
}
func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) {
t.Helper()
var requestBody bytes.Buffer
@@ -470,41 +341,6 @@ func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPos
return preparePostImage(req.MultipartForm.File["images"][0])
}
func multipartFileHeader(t *testing.T, name string, body []byte) *multipart.FileHeader {
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)
}
t.Cleanup(func() { _ = req.MultipartForm.RemoveAll() })
return req.MultipartForm.File["images"][0]
}
func tinyMP4() []byte {
body := make([]byte, 16)
body[3] = 16
copy(body[4:], "ftypisom")
copy(body[12:], "isom")
return body
}
func tinyWebM() []byte {
return []byte{0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00, 0x00, 0x00}
}
func solidPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
@@ -538,7 +374,6 @@ func solidJPEG(t *testing.T, width, height int) []byte {
type recordedImageUpload struct {
key string
contentType string
size int64
body []byte
}
@@ -566,7 +401,6 @@ func (b *recordingImageBlob) Upload(_ context.Context, object blob.FileUpload) (
b.uploads = append(b.uploads, recordedImageUpload{
key: object.Key,
contentType: object.ContentType,
size: object.Size,
body: body,
})
return "https://cdn.example/" + object.Key, nil
@@ -580,13 +414,9 @@ func (b *recordingImageBlob) Delete(_ context.Context, key string) error {
}
func (b *recordingImageBlob) uploadCount() int {
return len(b.recordedUploads())
}
func (b *recordingImageBlob) recordedUploads() []recordedImageUpload {
b.mu.Lock()
defer b.mu.Unlock()
return append([]recordedImageUpload(nil), b.uploads...)
return len(b.uploads)
}
func (b *recordingImageBlob) deletedKeys() []string {
+59 -2
View File
@@ -5,13 +5,16 @@ import (
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"plumber/internal/mail"
"plumber/internal/store"
)
@@ -93,7 +96,9 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if root == nil {
root = post
}
s.publishPostCreated(post, root, user)
if parent != nil {
s.notifyPostReply(parent, root, post, user)
}
http.Redirect(
w,
r,
@@ -102,6 +107,59 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
)
}
// notifyPostReply emails the root homeowner for admin replies and the direct
// parent author for homeowner replies.
func (s *Server) notifyPostReply(
parent *store.Post,
root *store.Post,
reply *store.Post,
replyAuthor *store.User,
) {
if parent == nil ||
root == nil ||
reply == nil ||
replyAuthor == nil ||
s.cfg.Mail == nil {
return
}
if _, disabled := s.cfg.Mail.(mail.Nop); disabled {
return
}
recipientID := parent.AuthorID
if replyAuthor.Admin() {
recipientID = root.AuthorID
}
if recipientID == replyAuthor.ID {
return
}
msg := mail.PostReply{
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: replyAuthor.Name,
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := s.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := s.cfg.Mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
}
// 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) {
@@ -160,7 +218,6 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
return
}
s.deletePostImageObjects(removedPostImageKeys(previousImages, images))
s.publishPostUpdated(post, root, nil)
http.Redirect(
w,
r,
+1 -96
View File
@@ -9,7 +9,6 @@ import (
"testing"
"time"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
@@ -264,12 +263,8 @@ func TestEditPostRoutePermissions(t *testing.T) {
func TestPostReplyNotifications(t *testing.T) {
t.Parallel()
mem := store.NewMemory()
bus := events.New()
defer bus.Close()
recording := &mail.Recording{}
mail.Subscribe(bus, mem, recording)
srv := newTestServerStore(t, mem, Config{Events: bus})
srv, mem := newTestServer(t, Config{Mail: recording})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("homeowner"), "hunter22", store.RoleUser)
admin := seedUser(t, mem, uniq("admin"), "hunter22", store.RoleAdmin)
@@ -418,12 +413,6 @@ 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)
@@ -432,11 +421,6 @@ 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)
@@ -445,12 +429,6 @@ 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)
@@ -482,19 +460,6 @@ 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 + `"`,
@@ -510,9 +475,6 @@ 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)
}
@@ -528,63 +490,6 @@ 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 {
+4 -17
View File
@@ -21,8 +21,8 @@ import (
"github.com/google/uuid"
"plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/geo"
"plumber/internal/mail"
"plumber/internal/pacific"
"plumber/internal/store"
)
@@ -35,8 +35,7 @@ type Config struct {
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
TrustedProxies []*net.IPNet
Blob blob.Uploader
Events events.Publisher
BaseURL string
Mail mail.Notifier
}
type Server struct {
@@ -105,17 +104,12 @@ 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{}
}
if cfg.Events == nil {
cfg.Events = events.Nop{}
if cfg.Mail == nil {
cfg.Mail = mail.Nop{}
}
funcMap := template.FuncMap{
"voteCtx": func(user *store.User, csrf, view, date string, post *store.Post) voteCtx {
@@ -124,12 +118,6 @@ 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() },
@@ -397,7 +385,6 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
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,11 +173,6 @@ 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())
+3 -24
View File
@@ -48,23 +48,13 @@ CREATE TABLE IF NOT EXISTS post_images (
object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
kind TEXT NOT NULL DEFAULT 'image',
position SMALLINT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3),
width INTEGER NOT NULL CHECK (width > 0),
height INTEGER NOT NULL CHECK (height > 0),
created_at TEXT NOT NULL,
CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video')),
CONSTRAINT post_images_width_check CHECK (width >= 0),
CONSTRAINT post_images_height_check CHECK (height >= 0),
CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0)),
CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4),
UNIQUE (post_id, position)
);
CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx
ON post_images (post_id)
WHERE kind = 'video';
CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
@@ -75,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,243 +749,6 @@ 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;
@@ -1034,16 +797,8 @@ 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,9 +1,5 @@
(() => {
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");
@@ -25,193 +21,6 @@
}
}
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) {
@@ -241,12 +50,9 @@
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
@@ -1,85 +0,0 @@
{{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}}
+3 -8
View File
@@ -3,13 +3,12 @@
{{if canReply .User .Root}}
<details class="post-composer">
<summary>Reply</summary>
<form class="post-form" method="post" action="/posts" enctype="multipart/form-data"
<form class="post-form" method="post" action="/posts"
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"
@@ -21,16 +20,13 @@
{{if canEditPost .User .Post}}
<details class="post-composer">
<summary>Edit</summary>
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit"
enctype="multipart/form-data"
data-submit-once data-submitting-label="Saving…">
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit">
<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" data-submit-button>Save changes</button>
<button type="submit" class="btn btn-primary">Save changes</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
@@ -63,7 +59,6 @@
</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,7 +17,6 @@
{{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>
+1 -2
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" enctype="multipart/form-data"
<form class="ask" method="post" action="/submit"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="title">Title</label>
@@ -13,7 +13,6 @@
<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>