Compare commits

..
Author SHA1 Message Date
codegirl007 fc8c0a6d9e Merge master into post-images-backend.
CI / test (pull_request) Successful in 6m25s
2026-08-30 21:29:03 -07:00
codegirl007 394c7413c4 Add post image upload interface (#12)
Adds accessible drag-and-drop and browse controls, staged attachment editing, responsive lazy-loaded thread image grids, and UI regression coverage.

Reviewed-on: #12
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-29 08:01:04 +00:00
codegirl007 677e63329d Add post image upload backend
CI / test (pull_request) Successful in 7m1s
2026-08-27 23:53:27 -07:00
codegirl007 1840a662d9 Add post image storage
CI / test (pull_request) Successful in 6m17s
2026-08-27 23:46:06 -07:00
32 changed files with 161 additions and 1526 deletions
+2 -4
View File
@@ -38,7 +38,6 @@ func main() {
} }
bus := events.New() bus := events.New()
mail.Subscribe(bus, store.NewPostgres(db), notifier) mail.Subscribe(bus, store.NewPostgres(db), notifier)
events.SubscribeRelease(bus)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db)) bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db))
if err != nil { if err != nil {
log.Fatalf("discord: %v", err) log.Fatalf("discord: %v", err)
@@ -46,7 +45,7 @@ func main() {
if bot != nil { if bot != nil {
defer bot.Close() defer bot.Close()
} }
handler := newHandler(db, sessions, uploader, bus, bot != nil) handler := newHandler(db, sessions, uploader, bus)
run(&http.Server{ run(&http.Server{
Addr: listenAddr(), Addr: listenAddr(),
Handler: handler, Handler: handler,
@@ -70,7 +69,7 @@ func openDB() (*sql.DB, *store.SessionStore) {
return db, sessions return db, sessions
} }
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, bus events.Publisher, holdUploads bool) http.Handler { func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader, bus events.Publisher) http.Handler {
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{ srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")), AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
SecureCookie: secureCookieFromEnv(), SecureCookie: secureCookieFromEnv(),
@@ -78,7 +77,6 @@ func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader
Blob: uploader, Blob: uploader,
Events: bus, Events: bus,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"), BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("APP_BASE_URL")), "/"),
HoldUploadUntilDiscord: holdUploads,
}) })
if err != nil { if err != nil {
log.Fatalf("server: %v", err) log.Fatalf("server: %v", err)
+3 -4
View File
@@ -63,7 +63,7 @@ WHERE id = sqlc.arg(id);
-- name: CreatePostImage :exec -- name: CreatePostImage :exec
INSERT INTO post_images ( 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 ( VALUES (
sqlc.arg(id), sqlc.arg(id),
@@ -71,7 +71,6 @@ VALUES (
sqlc.arg(object_key), sqlc.arg(object_key),
sqlc.arg(public_url), sqlc.arg(public_url),
sqlc.arg(description), sqlc.arg(description),
sqlc.arg(kind),
sqlc.arg(position), sqlc.arg(position),
sqlc.arg(width), sqlc.arg(width),
sqlc.arg(height), sqlc.arg(height),
@@ -84,7 +83,7 @@ WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many -- name: ListPostImages :many
SELECT 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 FROM post_images
WHERE post_id = sqlc.arg(post_id) WHERE post_id = sqlc.arg(post_id)
ORDER BY position; ORDER BY position;
@@ -103,7 +102,7 @@ WITH RECURSIVE thread AS (
) )
SELECT SELECT
images.id, images.post_id, images.object_key, images.public_url, 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 FROM post_images images
JOIN thread ON thread.id = images.post_id JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position; ORDER BY images.post_id, images.position;
+1 -1
View File
@@ -7,7 +7,6 @@ require (
github.com/aws/aws-sdk-go-v2 v1.43.7 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/credentials v1.19.37
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 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/bwmarrin/discordgo v0.29.0
github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/chi/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
@@ -28,6 +27,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect 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/presigned-url v1.13.38 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
github.com/aws/smithy-go v1.27.8 // indirect
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
-15
View File
@@ -8,11 +8,9 @@ import (
"strings" "strings"
"github.com/aws/aws-sdk-go-v2/aws" "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/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go/middleware"
) )
// Uploader stores public avatar objects. // Uploader stores public avatar objects.
@@ -83,23 +81,10 @@ func NewSpaces(cfg SpacesConfig) Uploader {
Region: cfg.Region, Region: cfg.Region,
Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""), Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""),
BaseEndpoint: aws.String(cfg.Endpoint), BaseEndpoint: aws.String(cfg.Endpoint),
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
APIOptions: []func(*middleware.Stack) error{
spacesUnsignedPayload,
},
}) })
return &spaces{client: client, cfg: cfg} 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) Enabled() bool { return true }
func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) { func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
+1 -28
View File
@@ -2,7 +2,6 @@ package discord
import ( import (
"context" "context"
"strings"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -64,27 +63,14 @@ func (s *sessionAPI) Close() error {
} }
func toMessageSend(msg Message) *discordgo.MessageSend { func toMessageSend(msg Message) *discordgo.MessageSend {
var files []*discordgo.File
for _, f := range msg.Files {
files = append(files, &discordgo.File{
Name: f.Name,
ContentType: f.ContentType,
Reader: f.Reader,
})
}
return &discordgo.MessageSend{ return &discordgo.MessageSend{
Content: messageContent(msg), Content: messageContent(msg),
Embeds: toEmbeds(msg), Embeds: toEmbeds(msg),
Files: files,
AllowedMentions: &discordgo.MessageAllowedMentions{}, AllowedMentions: &discordgo.MessageAllowedMentions{},
} }
} }
func toEmbeds(msg Message) []*discordgo.MessageEmbed { func toEmbeds(msg Message) []*discordgo.MessageEmbed {
imageURLs := embedImageURLs(msg)
if msg.Title == "" && msg.Description == "" && msg.City == "" && msg.Author == "" && len(imageURLs) == 0 {
return nil
}
main := &discordgo.MessageEmbed{ main := &discordgo.MessageEmbed{
Title: msg.Title, Title: msg.Title,
URL: publicURL(msg.URL), URL: publicURL(msg.URL),
@@ -106,7 +92,7 @@ func toEmbeds(msg Message) []*discordgo.MessageEmbed {
}) })
} }
embeds := []*discordgo.MessageEmbed{main} embeds := []*discordgo.MessageEmbed{main}
for i, url := range imageURLs { for i, url := range msg.ImageURLs {
if i == 0 { if i == 0 {
main.Image = &discordgo.MessageEmbedImage{URL: url} main.Image = &discordgo.MessageEmbedImage{URL: url}
continue continue
@@ -118,16 +104,3 @@ func toEmbeds(msg Message) []*discordgo.MessageEmbed {
} }
return embeds return embeds
} }
func embedImageURLs(msg Message) []string {
var attached []string
for _, f := range msg.Files {
if strings.HasPrefix(f.ContentType, "image/") {
attached = append(attached, "attachment://"+f.Name)
}
}
if len(attached) > 0 {
return attached
}
return msg.ImageURLs
}
+24 -77
View File
@@ -16,7 +16,7 @@ import (
"plumber/internal/store" "plumber/internal/store"
) )
const discordTimeout = 60 * time.Second const discordTimeout = 15 * time.Second
// Bot posts site events to a Discord channel and owns post-to-message links. // Bot posts site events to a Discord channel and owns post-to-message links.
type Bot struct { type Bot struct {
@@ -91,27 +91,13 @@ func (b *Bot) Handle(_ context.Context, ev any) {
defer cancel() defer cancel()
switch e := ev.(type) { switch e := ev.(type) {
case events.PostCreated: case events.PostCreated:
defer b.publishPosted(e) b.onCreated(ctx, e.PostEvent)
b.onCreated(ctx, e)
case events.PostUpdated: case events.PostUpdated:
b.onUpdated(ctx, e.PostEvent) b.onUpdated(ctx, e.PostEvent)
} }
} }
func (b *Bot) publishPosted(e events.PostCreated) { func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
if e.Release == nil {
return
}
done := events.PostedToDiscord{PostID: e.PostID, Release: e.Release}
if b.bus == nil {
e.Release()
return
}
b.bus.Publish(context.Background(), done)
}
func (b *Bot) onCreated(ctx context.Context, e events.PostCreated) {
ev := e.PostEvent
_, err := b.links.GetByPostID(ctx, ev.PostID) _, err := b.links.GetByPostID(ctx, ev.PostID)
if err == nil { if err == nil {
return return
@@ -121,17 +107,17 @@ func (b *Bot) onCreated(ctx context.Context, e events.PostCreated) {
return return
} }
if isRoot(ev) { if isRoot(ev) {
b.createRoot(ctx, e) b.createRoot(ctx, ev)
return return
} }
b.createReply(ctx, e) b.createReply(ctx, ev)
} }
func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) { func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
link, err := b.links.GetByPostID(ctx, ev.PostID) link, err := b.links.GetByPostID(ctx, ev.PostID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
b.onCreated(ctx, events.PostCreated{PostEvent: ev}) b.onCreated(ctx, ev)
return return
} }
log.Printf("discord: load link %s: %v", ev.PostID, err) log.Printf("discord: load link %s: %v", ev.PostID, err)
@@ -149,95 +135,56 @@ func (b *Bot) onUpdated(ctx context.Context, ev events.PostEvent) {
log.Printf("discord: edited %s", ev.PostID) log.Printf("discord: edited %s", ev.PostID)
} }
func (b *Bot) createRoot(ctx context.Context, e events.PostCreated) { func (b *Bot) createRoot(ctx context.Context, ev events.PostEvent) {
msg := formatCreated(e) msg := formatMessage(ev)
threadID, err := b.api.StartThread(ctx, b.channelID, msg.ThreadName) threadID, err := b.api.StartThread(ctx, b.channelID, msg.ThreadName)
if err != nil { if err != nil {
log.Printf("discord: start thread %s: %v", e.PostID, err) log.Printf("discord: start thread %s: %v", ev.PostID, err)
return return
} }
messageID, err := b.sendCreated(ctx, threadID, msg) messageID, err := b.api.SendToThread(ctx, threadID, msg)
if err != nil { if err != nil {
log.Printf("discord: send root %s: %v", e.PostID, err) log.Printf("discord: send root %s: %v", ev.PostID, err)
return return
} }
if err := b.links.Upsert(ctx, store.DiscordLink{ if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: e.PostID, PostID: ev.PostID,
MessageID: messageID, MessageID: messageID,
ThreadID: threadID, ThreadID: threadID,
}); err != nil { }); err != nil {
log.Printf("discord: save root link %s: %v", e.PostID, err) log.Printf("discord: save root link %s: %v", ev.PostID, err)
return return
} }
log.Printf("discord: posted root %s", e.PostID) log.Printf("discord: posted root %s", ev.PostID)
} }
func (b *Bot) createReply(ctx context.Context, e events.PostCreated) { func (b *Bot) createReply(ctx context.Context, ev events.PostEvent) {
root, err := b.links.GetByPostID(ctx, e.RootID) root, err := b.links.GetByPostID(ctx, ev.RootID)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: skip reply %s: no root thread", e.PostID) log.Printf("discord: skip reply %s: no root thread", ev.PostID)
return return
} }
log.Printf("discord: load root link %s: %v", e.RootID, err) log.Printf("discord: load root link %s: %v", ev.RootID, err)
return return
} }
if strings.TrimSpace(root.ThreadID) == "" { if strings.TrimSpace(root.ThreadID) == "" {
log.Printf("discord: skip reply %s: no root thread", e.PostID) log.Printf("discord: skip reply %s: no root thread", ev.PostID)
return return
} }
messageID, err := b.sendCreated(ctx, root.ThreadID, formatCreated(e)) messageID, err := b.api.SendToThread(ctx, root.ThreadID, formatMessage(ev))
if err != nil { if err != nil {
log.Printf("discord: send reply %s: %v", e.PostID, err) log.Printf("discord: send reply %s: %v", ev.PostID, err)
return return
} }
if err := b.links.Upsert(ctx, store.DiscordLink{ if err := b.links.Upsert(ctx, store.DiscordLink{
PostID: e.PostID, PostID: ev.PostID,
MessageID: messageID, MessageID: messageID,
}); err != nil { }); err != nil {
log.Printf("discord: save reply link %s: %v", e.PostID, err) log.Printf("discord: save reply link %s: %v", ev.PostID, err)
return return
} }
log.Printf("discord: posted reply %s", e.PostID) log.Printf("discord: posted reply %s", ev.PostID)
}
func (b *Bot) sendCreated(ctx context.Context, threadID string, msg Message) (string, error) {
_, videos := splitAttachments(msg.Files)
msg.Files = nil
if len(videos) > 0 {
msg.VideoURLs = nil
}
messageID, err := b.send(ctx, threadID, msg)
if err != nil {
return "", err
}
for _, video := range videos {
if _, err := b.send(ctx, threadID, Message{Files: []Attachment{video}}); err != nil {
log.Printf("discord: send video %s: %v", threadID, err)
}
}
return messageID, nil
}
func splitAttachments(files []Attachment) (images, videos []Attachment) {
for _, f := range files {
if strings.HasPrefix(f.ContentType, "video/") || f.Kind == "video" {
videos = append(videos, f)
continue
}
images = append(images, f)
}
return images, videos
}
func (b *Bot) send(ctx context.Context, threadID string, msg Message) (string, error) {
messageID, err := b.api.SendToThread(ctx, threadID, msg)
if err != nil && len(msg.Files) > 0 {
log.Printf("discord: send with files %s: %v; retrying without files", threadID, err)
msg.Files = nil
return b.api.SendToThread(ctx, threadID, msg)
}
return messageID, err
} }
func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) { func (b *Bot) editChannel(ctx context.Context, ev events.PostEvent, link *store.DiscordLink) (string, error) {
+3 -129
View File
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"plumber/internal/events" "plumber/internal/events"
"plumber/internal/store" "plumber/internal/store"
@@ -210,125 +209,6 @@ func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
} }
} }
func TestOutboundAttachesVideoFile(t *testing.T) {
t.Parallel()
bus := events.New()
defer bus.Close()
events.SubscribeRelease(bus)
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
bot.bus = bus
released := make(chan struct{})
bot.Handle(context.Background(), events.PostCreated{
PostEvent: events.PostEvent{
PostID: "root-1",
RootID: "root-1",
Title: "Valve clip",
Body: "Watch the handle.",
AuthorName: "sam",
Permalink: "https://www.askaplumberfirst.com/questions/root-1#post-root-1",
Images: []events.Image{{URL: "https://cdn.example/clip.mp4", Kind: "video"}},
},
Media: []events.Media{{
Name: "clip.mp4",
ContentType: "video/mp4",
Kind: "video",
Bytes: []byte("fake-mp4"),
}},
Release: func() { close(released) },
})
if len(api.sends) != 3 ||
api.sends[1].Kind != "thread-msg" ||
api.sends[2].Kind != "thread-msg" {
t.Fatalf("sends = %+v", api.sends)
}
if len(api.sends[1].Msg.Files) != 0 {
t.Fatalf("text message files = %+v", api.sends[1].Msg.Files)
}
files := api.sends[2].Msg.Files
if len(files) != 1 || files[0].Name != "clip.mp4" || files[0].ContentType != "video/mp4" {
t.Fatalf("video files = %+v", files)
}
content := messageContent(api.sends[1].Msg)
if strings.Contains(content, "https://cdn.example/clip.mp4") {
t.Fatalf("content still has video URL: %q", content)
}
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("PostedToDiscord did not release")
}
}
func TestOutboundPhotosThenVideo(t *testing.T) {
t.Parallel()
api := &fakeAPI{}
bot := New("channel-1", newMemoryLinks(), api)
bot.Handle(context.Background(), events.PostCreated{
PostEvent: events.PostEvent{
PostID: "root-1",
RootID: "root-1",
Title: "Valve clip",
Body: "Photo and video.",
AuthorName: "sam",
Images: []events.Image{
{URL: "https://cdn.example/a.jpg"},
{URL: "https://cdn.example/clip.mp4", Kind: "video"},
},
},
Media: []events.Media{
{Name: "still.jpg", ContentType: "image/jpeg", Kind: "image", Bytes: []byte("jpeg")},
{Name: "clip.mp4", ContentType: "video/mp4", Kind: "video", Bytes: []byte("mp4")},
},
})
if len(api.sends) != 3 ||
api.sends[1].Kind != "thread-msg" ||
api.sends[2].Kind != "thread-msg" {
t.Fatalf("sends = %+v", api.sends)
}
photos := api.sends[1].Msg.Files
if len(photos) != 0 {
t.Fatalf("text message should not attach files: %+v", photos)
}
if got := api.sends[1].Msg.ImageURLs; len(got) != 1 || got[0] != "https://cdn.example/a.jpg" {
t.Fatalf("text message images = %v", got)
}
if embeds := toEmbeds(api.sends[1].Msg); len(embeds) == 0 || embeds[0].Image == nil || embeds[0].Image.URL != "https://cdn.example/a.jpg" {
t.Fatalf("text embeds = %+v", toEmbeds(api.sends[1].Msg))
}
videos := api.sends[2].Msg.Files
if len(videos) != 1 || videos[0].Name != "clip.mp4" {
t.Fatalf("video files = %+v", videos)
}
}
func TestOutboundReleasesWhenAlreadyLinked(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "root-1",
MessageID: "d-root",
ThreadID: "thread-1",
}); err != nil {
t.Fatal(err)
}
released := false
bot := New("channel-1", links, &fakeAPI{})
bot.Handle(context.Background(), events.PostCreated{
PostEvent: events.PostEvent{PostID: "root-1", RootID: "root-1", Title: "Already posted"},
Release: func() { released = true },
})
if !released {
t.Fatal("skipped send did not release held upload")
}
}
func TestFormatMessage(t *testing.T) { func TestFormatMessage(t *testing.T) {
t.Parallel() t.Parallel()
@@ -338,10 +218,7 @@ func TestFormatMessage(t *testing.T) {
City: "Oakland", City: "Oakland",
AuthorName: "sam", AuthorName: "sam",
Permalink: "https://example.com/q", Permalink: "https://example.com/q",
Images: []events.Image{ Images: []events.Image{{URL: "https://cdn.example/a.jpg", Description: "ignored"}},
{URL: "https://cdn.example/a.jpg", Description: "ignored"},
{URL: "https://cdn.example/clip.mp4", Kind: "video"},
},
}) })
if got.Title != "Leaky sink" || if got.Title != "Leaky sink" ||
got.Description != "It drips." || got.Description != "It drips." ||
@@ -349,8 +226,7 @@ func TestFormatMessage(t *testing.T) {
got.Author != "sam" || got.Author != "sam" ||
got.URL != "https://example.com/q" || got.URL != "https://example.com/q" ||
got.ThreadName != "sam asks: Leaky sink" || got.ThreadName != "sam asks: Leaky sink" ||
len(got.ImageURLs) != 1 || got.ImageURLs[0] != "https://cdn.example/a.jpg" || len(got.ImageURLs) != 1 {
len(got.VideoURLs) != 1 || got.VideoURLs[0] != "https://cdn.example/clip.mp4" {
t.Fatalf("format = %+v", got) t.Fatalf("format = %+v", got)
} }
@@ -361,9 +237,7 @@ func TestFormatMessage(t *testing.T) {
content := messageContent(got) content := messageContent(got)
if strings.Contains(content, "Leaky sink") || if strings.Contains(content, "Leaky sink") ||
!strings.Contains(content, "It drips.") || !strings.Contains(content, "It drips.") ||
!strings.Contains(content, "Oakland") || !strings.Contains(content, "Oakland") {
!strings.Contains(content, "https://cdn.example/a.jpg") ||
!strings.Contains(content, "https://cdn.example/clip.mp4") {
t.Fatalf("content = %q", content) t.Fatalf("content = %q", content)
} }
if publicURL("/questions/x") != "" || publicURL("http://localhost:8080/q") != "" { if publicURL("/questions/x") != "" || publicURL("http://localhost:8080/q") != "" {
+7 -69
View File
@@ -1,8 +1,6 @@
package discord package discord
import ( import (
"bytes"
"io"
"strings" "strings"
"plumber/internal/events" "plumber/internal/events"
@@ -15,14 +13,6 @@ const (
embedColor = 0xe96a26 embedColor = 0xe96a26
) )
// Attachment is a local file Discord should upload with the message.
type Attachment struct {
Name string
ContentType string
Kind string
Reader io.Reader
}
// Message is a Discord-ready snapshot of a site post event. // Message is a Discord-ready snapshot of a site post event.
type Message struct { type Message struct {
Title string Title string
@@ -31,76 +21,36 @@ type Message struct {
City string City string
Author string Author string
ImageURLs []string ImageURLs []string
VideoURLs []string
Files []Attachment
ThreadName string ThreadName string
} }
func formatMessage(ev events.PostEvent) Message { func formatMessage(ev events.PostEvent) Message {
return formatCreated(events.PostCreated{PostEvent: ev}) title := strings.TrimSpace(ev.Title)
}
func formatCreated(e events.PostCreated) Message {
title := strings.TrimSpace(e.Title)
if title == "" { if title == "" {
title = "Reply" title = "Reply"
} }
author := strings.TrimSpace(e.AuthorName) author := strings.TrimSpace(ev.AuthorName)
if author == "" { if author == "" {
author = "Someone" author = "Someone"
} }
msg := Message{ msg := Message{
Title: truncateRunes(title, embedTitleLimit), Title: truncateRunes(title, embedTitleLimit),
URL: strings.TrimSpace(e.Permalink), URL: strings.TrimSpace(ev.Permalink),
Description: truncateRunes(strings.TrimSpace(e.Body), embedDescriptionLimit), Description: truncateRunes(strings.TrimSpace(ev.Body), embedDescriptionLimit),
City: strings.TrimSpace(e.City), City: strings.TrimSpace(ev.City),
Author: author, Author: author,
ThreadName: threadName(author, e.Title), ThreadName: threadName(author, ev.Title),
Files: filesFromMedia(e.Media),
} }
for _, img := range e.Images { for _, img := range ev.Images {
url := strings.TrimSpace(img.URL) url := strings.TrimSpace(img.URL)
if url == "" { if url == "" {
continue continue
} }
if img.Kind == "video" {
msg.VideoURLs = append(msg.VideoURLs, url)
continue
}
msg.ImageURLs = append(msg.ImageURLs, url) msg.ImageURLs = append(msg.ImageURLs, url)
} }
return msg return msg
} }
func filesFromMedia(media []events.Media) []Attachment {
if len(media) == 0 {
return nil
}
files := make([]Attachment, 0, len(media))
for _, m := range media {
var r io.Reader
switch {
case len(m.Bytes) > 0:
r = bytes.NewReader(m.Bytes)
case m.Body != nil:
r = m.Body
default:
continue
}
name := strings.TrimSpace(m.Name)
if name == "" {
name = "upload"
}
files = append(files, Attachment{
Name: name,
ContentType: m.ContentType,
Kind: m.Kind,
Reader: r,
})
}
return files
}
func threadName(author, title string) string { func threadName(author, title string) string {
author = strings.TrimSpace(author) author = strings.TrimSpace(author)
if author == "" { if author == "" {
@@ -146,18 +96,6 @@ func messageContent(msg Message) string {
if u := publicURL(msg.URL); u != "" { if u := publicURL(msg.URL); u != "" {
parts = append(parts, u) parts = append(parts, u)
} }
if len(msg.Files) == 0 {
for _, mediaURL := range msg.ImageURLs {
if u := publicURL(mediaURL); u != "" {
parts = append(parts, u)
}
}
for _, mediaURL := range msg.VideoURLs {
if u := publicURL(mediaURL); u != "" {
parts = append(parts, u)
}
}
}
return truncateRunes(strings.Join(parts, "\n"), 2000) return truncateRunes(strings.Join(parts, "\n"), 2000)
} }
-2
View File
@@ -40,14 +40,12 @@ func newBus(buffer int, start bool) *Bus {
// Publish enqueues ev. It never blocks the caller; a full buffer is dropped. // Publish enqueues ev. It never blocks the caller; a full buffer is dropped.
func (b *Bus) Publish(_ context.Context, ev any) { func (b *Bus) Publish(_ context.Context, ev any) {
if b == nil { if b == nil {
CallRelease(ev)
return return
} }
select { select {
case b.ch <- ev: case b.ch <- ev:
default: default:
log.Printf("events: dropped %T", ev) log.Printf("events: dropped %T", ev)
CallRelease(ev)
} }
} }
-41
View File
@@ -102,44 +102,3 @@ func TestBusDropsWhenFull(t *testing.T) {
default: default:
} }
} }
func TestNopReleasesPostCreated(t *testing.T) {
t.Parallel()
released := false
Nop{}.Publish(context.Background(), PostCreated{Release: func() { released = true }})
if !released {
t.Fatal("Nop.Publish did not release held upload")
}
}
func TestBusDropReleasesPostCreated(t *testing.T) {
t.Parallel()
bus := newBus(1, false)
bus.Publish(context.Background(), "kept")
released := false
bus.Publish(context.Background(), PostCreated{Release: func() { released = true }})
if !released {
t.Fatal("dropped PostCreated did not release held upload")
}
}
func TestSubscribeReleasePostedToDiscord(t *testing.T) {
t.Parallel()
bus := New()
defer bus.Close()
SubscribeRelease(bus)
released := make(chan struct{})
bus.Publish(context.Background(), PostedToDiscord{
PostID: "post-1",
Release: func() { close(released) },
})
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("PostedToDiscord did not release")
}
}
+1 -23
View File
@@ -1,27 +1,14 @@
package events package events
import ( import (
"io"
"net/url" "net/url"
"strings" "strings"
) )
// Image is a public photo or video already attached to a site post. // Image is a public photo already attached to a site post.
type Image struct { type Image struct {
URL string URL string
Description string Description string
Kind string
}
// Media is a local upload still held from the create request so Discord can
// attach the bytes. Photos carry re-encoded Bytes; videos carry an open Body.
type Media struct {
Name string
ContentType string
Kind string
Size int64
Body io.ReadCloser
Bytes []byte
} }
// PostEvent is a Discord-free snapshot of a site post after a successful write. // PostEvent is a Discord-free snapshot of a site post after a successful write.
@@ -42,8 +29,6 @@ type PostEvent struct {
// PostCreated is emitted after a successful site create. // PostCreated is emitted after a successful site create.
type PostCreated struct { type PostCreated struct {
PostEvent PostEvent
Media []Media `json:"-"`
Release func() `json:"-"`
} }
// PostUpdated is emitted after a successful site edit. // PostUpdated is emitted after a successful site edit.
@@ -51,13 +36,6 @@ type PostUpdated struct {
PostEvent PostEvent
} }
// PostedToDiscord is emitted after the Discord subscriber finishes with a
// PostCreated (sent, skipped, or failed). Release deletes held upload files.
type PostedToDiscord struct {
PostID string
Release func() `json:"-"`
}
// Permalink builds /questions/{root}#post-{id}, prefixed by baseURL when set. // Permalink builds /questions/{root}#post-{id}, prefixed by baseURL when set.
func Permalink(baseURL, rootID, postID string) string { func Permalink(baseURL, rootID, postID string) string {
path := "/questions/" + url.PathEscape(rootID) + "#post-" + url.PathEscape(postID) path := "/questions/" + url.PathEscape(rootID) + "#post-" + url.PathEscape(postID)
+2 -4
View File
@@ -5,7 +5,5 @@ import "context"
// Nop is a Publisher used when nothing is subscribed. // Nop is a Publisher used when nothing is subscribed.
type Nop struct{} type Nop struct{}
// Publish discards ev after releasing any held upload. // Publish discards ev.
func (Nop) Publish(_ context.Context, ev any) { func (Nop) Publish(context.Context, any) {}
CallRelease(ev)
}
-41
View File
@@ -1,41 +0,0 @@
package events
import (
"context"
"sync"
)
// Once returns fn wrapped so it runs at most once.
func Once(fn func()) func() {
if fn == nil {
return func() {}
}
var once sync.Once
return func() { once.Do(fn) }
}
// CallRelease runs Release on PostCreated or PostedToDiscord when set.
func CallRelease(ev any) {
switch e := ev.(type) {
case PostCreated:
if e.Release != nil {
e.Release()
}
case PostedToDiscord:
if e.Release != nil {
e.Release()
}
}
}
// SubscribeRelease runs PostedToDiscord.Release on the worker.
func SubscribeRelease(bus *Bus) {
if bus == nil {
return
}
bus.Subscribe(func(_ context.Context, ev any) {
if _, ok := ev.(PostedToDiscord); ok {
CallRelease(ev)
}
})
}
-27
View File
@@ -158,32 +158,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
return nil 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 { func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
@@ -377,7 +351,6 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables}, {"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages}, {"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks}, {"011_discord_post_links", migrateDiscordPostLinks},
{"012_post_image_video", migratePostImageVideo},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+5 -25
View File
@@ -87,12 +87,6 @@ CREATE TABLE users (
if err := migrateDiscordPostLinks(ctx, conn); err != nil { if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err) 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, ` if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role) INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin'); VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
@@ -124,9 +118,9 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
} }
imageQueries := sqlc.New(conn) imageQueries := sqlc.New(conn)
for _, image := range []sqlc.CreatePostImageParams{ 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-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", Kind: "image", Position: 1, Width: 900, Height: 1200, 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", Kind: "image", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09: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 { if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -150,24 +144,10 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
} }
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{ if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg", 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", Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil { }); err == nil {
t.Fatal("position 5 unexpectedly succeeded") t.Fatal("fifth image position 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{ if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1", PostID: "root-1",
+3 -32
View File
@@ -29,20 +29,16 @@ const (
PostStateHidden PostState = "hidden" PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked" PostStateLocked PostState = "locked"
MaxPostImages = 4 MaxPostImages = 4
MaxPostVideos = 1
MaxImageDescriptionRunes = 500 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 { type PostImage struct {
ID string ID string
PostID string PostID string
ObjectKey string ObjectKey string
PublicURL string PublicURL string
Description string Description string
Kind string
Position int Position int
Width int Width int
Height int Height int
@@ -204,13 +200,12 @@ func preparePost(p *Post) error {
} }
func preparePostImages(p *Post) error { func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages+MaxPostVideos { if len(p.Images) > MaxPostImages {
return fmt.Errorf("%w: at most %d images and %d video are allowed", ErrInvalidPost, MaxPostImages, MaxPostVideos) return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
} }
ids := make(map[string]bool, len(p.Images)) ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images)) keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)
images, videos := 0, 0
for i := range p.Images { for i := range p.Images {
image := &p.Images[i] image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID) image.ID = strings.TrimSpace(image.ID)
@@ -218,13 +213,6 @@ func preparePostImages(p *Post) error {
image.ObjectKey = strings.TrimSpace(image.ObjectKey) image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL) image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description) 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 == "" { if image.ID == "" {
image.ID = uuid.NewString() image.ID = uuid.NewString()
} }
@@ -240,19 +228,10 @@ func preparePostImages(p *Post) error {
if len([]rune(image.Description)) > MaxImageDescriptionRunes { if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost) return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
} }
if image.Kind == MediaKindImage {
images++
if image.Width <= 0 || image.Height <= 0 || if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost) 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] { if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost) return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
} }
@@ -263,12 +242,6 @@ func preparePostImages(p *Post) error {
image.CreatedAt = now 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 return nil
} }
@@ -280,7 +253,6 @@ func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage)
ObjectKey: image.ObjectKey, ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL, PublicUrl: image.PublicURL,
Description: image.Description, Description: image.Description,
Kind: image.Kind,
Position: int16(image.Position), Position: int16(image.Position),
Width: int32(image.Width), Width: int32(image.Width),
Height: int32(image.Height), Height: int32(image.Height),
@@ -299,7 +271,6 @@ func postImageFromSQL(image sqlc.PostImage) PostImage {
ObjectKey: image.ObjectKey, ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl, PublicURL: image.PublicUrl,
Description: image.Description, Description: image.Description,
Kind: image.Kind,
Position: int(image.Position), Position: int(image.Position),
Width: int(image.Width), Width: int(image.Width),
Height: int(image.Height), 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) { if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err) 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 { func validPostImages(count int) []PostImage {
-1
View File
@@ -35,7 +35,6 @@ type PostImage struct {
ObjectKey string ObjectKey string
PublicUrl string PublicUrl string
Description string Description string
Kind string
Position int16 Position int16
Width int32 Width int32
Height 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 const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images ( 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 ( VALUES (
$1, $1,
@@ -70,8 +70,7 @@ VALUES (
$6, $6,
$7, $7,
$8, $8,
$9, $9
$10
) )
` `
@@ -81,7 +80,6 @@ type CreatePostImageParams struct {
ObjectKey string ObjectKey string
PublicUrl string PublicUrl string
Description string Description string
Kind string
Position int16 Position int16
Width int32 Width int32
Height int32 Height int32
@@ -95,7 +93,6 @@ func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams
arg.ObjectKey, arg.ObjectKey,
arg.PublicUrl, arg.PublicUrl,
arg.Description, arg.Description,
arg.Kind,
arg.Position, arg.Position,
arg.Width, arg.Width,
arg.Height, arg.Height,
@@ -204,7 +201,7 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
const listPostImages = `-- name: ListPostImages :many const listPostImages = `-- name: ListPostImages :many
SELECT 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 FROM post_images
WHERE post_id = $1 WHERE post_id = $1
ORDER BY position ORDER BY position
@@ -225,7 +222,6 @@ func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImag
&i.ObjectKey, &i.ObjectKey,
&i.PublicUrl, &i.PublicUrl,
&i.Description, &i.Description,
&i.Kind,
&i.Position, &i.Position,
&i.Width, &i.Width,
&i.Height, &i.Height,
@@ -331,7 +327,7 @@ WITH RECURSIVE thread AS (
) )
SELECT SELECT
images.id, images.post_id, images.object_key, images.public_url, 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 FROM post_images images
JOIN thread ON thread.id = images.post_id JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position ORDER BY images.post_id, images.position
@@ -352,7 +348,6 @@ func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]Po
&i.ObjectKey, &i.ObjectKey,
&i.PublicUrl, &i.PublicUrl,
&i.Description, &i.Description,
&i.Kind,
&i.Position, &i.Position,
&i.Width, &i.Width,
&i.Height, &i.Height,
+2 -21
View File
@@ -7,26 +7,8 @@ import (
"plumber/internal/store" "plumber/internal/store"
) )
func (s *Server) publishPostCreated(post, root *store.Post, author *store.User, media []events.Media, cleanup func()) { func (s *Server) publishPostCreated(post, root *store.Post, author *store.User) {
release := events.Once(func() { s.publishPost(events.PostCreated{PostEvent: s.postEvent(post, root, author)}, root)
closeHeldMedia(media)
if cleanup != nil {
cleanup()
}
})
if root != nil && root.PostState == store.PostStateHidden {
release()
return
}
ev := events.PostCreated{PostEvent: s.postEvent(post, root, author)}
if s.cfg.HoldUploadUntilDiscord && len(media) > 0 {
ev.Media = media
ev.Release = release
s.cfg.Events.Publish(context.Background(), ev)
return
}
release()
s.cfg.Events.Publish(context.Background(), events.PostCreated{PostEvent: ev.PostEvent})
} }
func (s *Server) publishPostUpdated(post, root *store.Post, author *store.User) { func (s *Server) publishPostUpdated(post, root *store.Post, author *store.User) {
@@ -76,7 +58,6 @@ func (s *Server) postEvent(post, root *store.Post, author *store.User) events.Po
ev.Images = append(ev.Images, events.Image{ ev.Images = append(ev.Images, events.Image{
URL: img.PublicURL, URL: img.PublicURL,
Description: img.Description, Description: img.Description,
Kind: img.Kind,
}) })
} }
} }
-72
View File
@@ -1,10 +1,8 @@
package web package web
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -238,73 +236,3 @@ func assertPostEvent(t *testing.T, got, want events.PostEvent) {
t.Fatalf("event = %+v, want %+v", got, want) t.Fatalf("event = %+v, want %+v", got, want)
} }
} }
func TestPostEventIncludesPhotosAndVideos(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) != 2 ||
got.Images[0].URL != "https://cdn.example/a.jpg" || got.Images[0].Kind != store.MediaKindImage ||
got.Images[1].URL != "https://cdn.example/a.mp4" || got.Images[1].Kind != store.MediaKindVideo {
t.Fatalf("event images = %+v", got.Images)
}
}
func TestCreatePostHoldsVideoUntilRelease(t *testing.T) {
t.Parallel()
rec := &events.Recording{}
blobs := &recordingImageBlob{}
srv, mem := newTestServer(t, Config{
Events: rec,
Blob: blobs,
HoldUploadUntilDiscord: true,
})
handler := srv.Handler()
user := seedUser(t, mem, uniq("hold-video"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
clip := tinyMP4()
res := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Valve clip"},
"body": {"Watch the handle."},
"city": {"Oakland"},
}, []multipartTestFile{{name: "walk.mp4", body: clip}}, cookies)
if res.Code != http.StatusSeeOther {
t.Fatalf("submit status = %d: %s", res.Code, res.Body.String())
}
got := rec.Snapshot()
if len(got) != 1 {
t.Fatalf("published %d events, want 1: %#v", len(got), got)
}
created, ok := got[0].(events.PostCreated)
if !ok {
t.Fatalf("event %T, want PostCreated", got[0])
}
if len(created.Media) != 1 ||
created.Media[0].Kind != store.MediaKindVideo ||
created.Media[0].ContentType != "video/mp4" ||
created.Media[0].Body == nil {
t.Fatalf("held media = %+v", created.Media)
}
if created.Release == nil {
t.Fatal("missing Release")
}
body, err := io.ReadAll(created.Media[0].Body)
if err != nil || !bytes.Equal(body, clip) {
t.Fatalf("held video body = %d bytes err=%v", len(body), err)
}
created.Release()
}
+21 -218
View File
@@ -22,15 +22,13 @@ import (
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/events"
"plumber/internal/store" "plumber/internal/store"
) )
const ( const (
defaultRequestBodyBytes = 3 << 20 defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20 postImageMaxFileBytes = 5 << 20
postVideoMaxFileBytes = 25 << 20 postImageMaxRequestBytes = 22 << 20
postImageMaxRequestBytes = 50 << 20 // 4 images + 1 video + form fields
postImageMultipartMemory = 2 << 20 postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000 postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000 postImageMaxSourcePixels = 16_000_000
@@ -69,7 +67,7 @@ func parsePostMutationForm(w http.ResponseWriter, r *http.Request) (func(), bool
contentType := r.Header.Get("Content-Type") contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType) mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") { 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 return func() {}, false
} }
if mediaType != "multipart/form-data" { if mediaType != "multipart/form-data" {
@@ -99,10 +97,10 @@ func writePostImageRequestError(w http.ResponseWriter, err error) {
} }
var maxErr *http.MaxBytesError var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) { 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 return
} }
http.Error(w, "Could not read upload.", http.StatusBadRequest) http.Error(w, "Could not read image upload.", http.StatusBadRequest)
} }
func (s *Server) postImagesFromForm( func (s *Server) postImagesFromForm(
@@ -110,38 +108,24 @@ func (s *Server) postImagesFromForm(
r *http.Request, r *http.Request,
postID string, postID string,
existing []store.PostImage, existing []store.PostImage,
) ([]store.PostImage, []string, []events.Media, error) { ) ([]store.PostImage, []string, error) {
if r.MultipartForm == nil { if r.MultipartForm == nil {
return append([]store.PostImage(nil), existing...), nil, nil, nil return append([]store.PostImage(nil), existing...), nil, nil
} }
retained, err := retainedPostImages(r.MultipartForm, existing) retained, err := retainedPostImages(r.MultipartForm, existing)
if err != nil { if err != nil {
return nil, nil, nil, err return nil, nil, err
} }
files := r.MultipartForm.File["images"] files := r.MultipartForm.File["images"]
descriptions := r.MultipartForm.Value["image_description"] descriptions := r.MultipartForm.Value["image_description"]
if len(descriptions) > len(files) { if len(descriptions) > len(files) {
return nil, nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil) return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
} }
kinds := make([]string, len(files)) if len(retained)+len(files) > store.MaxPostImages {
newImages, newVideos := 0, 0 return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
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 {
return nil, nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if retainedVideos+newVideos > store.MaxPostVideos {
return nil, nil, nil, invalidPostImage("You can attach one video.", nil)
} }
if len(files) > 0 && !s.cfg.Blob.Enabled() { if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, nil, &postImageRequestError{ return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable, status: http.StatusServiceUnavailable,
message: "Image uploads are not configured on this server.", message: "Image uploads are not configured on this server.",
} }
@@ -149,104 +133,19 @@ func (s *Server) postImagesFromForm(
images := append([]store.PostImage(nil), retained...) images := append([]store.PostImage(nil), retained...)
newKeys := make([]string, 0, len(files)) newKeys := make([]string, 0, len(files))
held := make([]events.Media, 0, len(files))
for i, header := range files { for i, header := range files {
description := "" description := ""
if i < len(descriptions) { if i < len(descriptions) {
description = strings.TrimSpace(descriptions[i]) description = strings.TrimSpace(descriptions[i])
} }
if len([]rune(description)) > store.MaxImageDescriptionRunes { if len([]rune(description)) > store.MaxImageDescriptionRunes {
closeHeldMedia(held)
s.deletePostImageObjects(newKeys) s.deletePostImageObjects(newKeys)
return nil, nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil) return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
item, objectKey, media, err := s.uploadPostMedia(ctx, postID, header, kinds[i], description)
if err != nil {
closeHeldMedia(held)
s.deletePostImageObjects(newKeys)
return nil, nil, nil, err
}
newKeys = append(newKeys, objectKey)
images = append(images, item)
held = append(held, media)
}
return images, newKeys, held, 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 closeHeldMedia(media []events.Media) {
for i := range media {
if media[i].Body != nil {
_ = media[i].Body.Close()
media[i].Body = nil
}
}
}
func (s *Server) uploadPostMedia(
ctx context.Context,
postID string,
header *multipart.FileHeader,
kind, description string,
) (store.PostImage, string, events.Media, error) {
if kind == store.MediaKindVideo {
prepared, err := preparePostVideo(header)
if err != nil {
return store.PostImage{}, "", events.Media{}, err
}
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 {
_ = prepared.body.Close()
return store.PostImage{}, "", events.Media{}, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload video. Try again later.",
cause: err,
}
}
media := events.Media{
Name: mediaID + prepared.extension,
ContentType: prepared.contentType,
Kind: store.MediaKindVideo,
Size: prepared.size,
}
if seeker, ok := prepared.body.(io.Seeker); ok {
if _, err := seeker.Seek(0, io.SeekStart); err == nil {
media.Body = prepared.body
} else {
_ = prepared.body.Close()
}
} else {
_ = prepared.body.Close()
}
return store.PostImage{
ID: mediaID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindVideo,
}, objectKey, media, nil
} }
prepared, err := preparePostImage(header) prepared, err := preparePostImage(header)
if err != nil { if err != nil {
return store.PostImage{}, "", events.Media{}, err s.deletePostImageObjects(newKeys)
return nil, nil, err
} }
imageID := uuid.NewString() imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension) objectKey := path.Join("post-images", postID, imageID+prepared.extension)
@@ -257,28 +156,25 @@ func (s *Server) uploadPostMedia(
Size: int64(len(prepared.body)), Size: int64(len(prepared.body)),
}) })
if err != nil { if err != nil {
return store.PostImage{}, "", events.Media{}, &postImageRequestError{ s.deletePostImageObjects(newKeys)
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable, status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.", message: "Could not upload image. Try again later.",
cause: err, cause: err,
} }
} }
return store.PostImage{ newKeys = append(newKeys, objectKey)
images = append(images, store.PostImage{
ID: imageID, ID: imageID,
PostID: postID, PostID: postID,
ObjectKey: objectKey, ObjectKey: objectKey,
PublicURL: publicURL, PublicURL: publicURL,
Description: description, Description: description,
Kind: store.MediaKindImage,
Width: prepared.width, Width: prepared.width,
Height: prepared.height, Height: prepared.height,
}, objectKey, events.Media{ })
Name: imageID + prepared.extension, }
ContentType: prepared.contentType, return images, newKeys, nil
Kind: store.MediaKindImage,
Size: int64(len(prepared.body)),
Bytes: prepared.body,
}, nil
} }
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) { func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
@@ -402,99 +298,6 @@ func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
return result, nil 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 { func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw)) metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil { if err != nil {
+1 -184
View File
@@ -12,7 +12,6 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync" "sync"
"testing" "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) { func TestOrientPostImage(t *testing.T) {
t.Parallel() t.Parallel()
@@ -245,100 +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 = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/questions/"+root.ID, nil)
for _, cookie := range cookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
page := rec.Body.String()
if rec.Code != http.StatusOK ||
!strings.Contains(page, `<video src="`+root.Images[1].PublicURL) ||
!strings.Contains(page, `data-existing-video`) {
t.Fatalf("question page missing video player: %d %s", rec.Code, page)
}
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) { func TestPostImageUploadCompensation(t *testing.T) {
t.Parallel() t.Parallel()
@@ -454,12 +318,6 @@ func multipartPost(
return rec 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) { func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) {
t.Helper() t.Helper()
var requestBody bytes.Buffer var requestBody bytes.Buffer
@@ -483,41 +341,6 @@ func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPos
return preparePostImage(req.MultipartForm.File["images"][0]) 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 { func solidPNG(t *testing.T, width, height int) []byte {
t.Helper() t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height)) img := image.NewNRGBA(image.Rect(0, 0, width, height))
@@ -551,7 +374,6 @@ func solidJPEG(t *testing.T, width, height int) []byte {
type recordedImageUpload struct { type recordedImageUpload struct {
key string key string
contentType string contentType string
size int64
body []byte body []byte
} }
@@ -579,7 +401,6 @@ func (b *recordingImageBlob) Upload(_ context.Context, object blob.FileUpload) (
b.uploads = append(b.uploads, recordedImageUpload{ b.uploads = append(b.uploads, recordedImageUpload{
key: object.Key, key: object.Key,
contentType: object.ContentType, contentType: object.ContentType,
size: object.Size,
body: body, body: body,
}) })
return "https://cdn.example/" + object.Key, nil return "https://cdn.example/" + object.Key, nil
@@ -593,13 +414,9 @@ func (b *recordingImageBlob) Delete(_ context.Context, key string) error {
} }
func (b *recordingImageBlob) uploadCount() int { func (b *recordingImageBlob) uploadCount() int {
return len(b.recordedUploads())
}
func (b *recordingImageBlob) recordedUploads() []recordedImageUpload {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
return append([]recordedImageUpload(nil), b.uploads...) return len(b.uploads)
} }
func (b *recordingImageBlob) deletedKeys() []string { func (b *recordingImageBlob) deletedKeys() []string {
+4 -14
View File
@@ -12,7 +12,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"plumber/internal/events"
"plumber/internal/store" "plumber/internal/store"
) )
@@ -23,14 +22,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
var media []events.Media defer cleanup()
held := false
defer func() {
if !held {
closeHeldMedia(media)
cleanup()
}
}()
if !s.requireCSRF(w, r) { if !s.requireCSRF(w, r) {
return return
} }
@@ -83,7 +75,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
root = threadRoot root = threadRoot
} }
images, newKeys, media, err := s.postImagesFromForm(r.Context(), r, post.ID, nil) images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil { if err != nil {
writePostImageRequestError(w, err) writePostImageRequestError(w, err)
return return
@@ -101,8 +93,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if root == nil { if root == nil {
root = post root = post
} }
s.publishPostCreated(post, root, user, media, cleanup) s.publishPostCreated(post, root, user)
held = true
http.Redirect( http.Redirect(
w, w,
r, r,
@@ -148,12 +139,11 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
return return
} }
previousImages := append([]store.PostImage(nil), post.Images...) previousImages := append([]store.PostImage(nil), post.Images...)
images, newKeys, media, err := s.postImagesFromForm(r.Context(), r, post.ID, previousImages) images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, previousImages)
if err != nil { if err != nil {
writePostImageRequestError(w, err) writePostImageRequestError(w, err)
return return
} }
closeHeldMedia(media)
post.Body = truncateRunes(body, 12000) post.Body = truncateRunes(body, 12000)
post.Images = images post.Images = images
if err := s.store.UpdatePost(r.Context(), post); err != nil { if err := s.store.UpdatePost(r.Context(), post); err != nil {
+1 -9
View File
@@ -484,19 +484,16 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`data-submit-button`, `data-submit-button`,
`enctype="multipart/form-data"`, `enctype="multipart/form-data"`,
`data-image-picker`, `data-image-picker`,
`accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,.mp4,.webm"`, `accept="image/jpeg,image/png,image/webp"`,
`aria-live="polite"`, `aria-live="polite"`,
`name="existing_image_id" value="root-photo"`, `name="existing_image_id" value="root-photo"`,
`name="existing_image_id" value="reply-photo"`, `name="existing_image_id" value="reply-photo"`,
`data-image-zoom`,
`href="https://cdn.example/root-photo.jpg"`,
`src="https://cdn.example/root-photo.jpg"`, `src="https://cdn.example/root-photo.jpg"`,
`alt="Water pooling below the shutoff valve"`, `alt="Water pooling below the shutoff valve"`,
`src="https://cdn.example/reply-photo.png"`, `src="https://cdn.example/reply-photo.png"`,
`alt="Photo attached to this post"`, `alt="Photo attached to this post"`,
`src="https://cdn.example/admin-photo.webp"`, `src="https://cdn.example/admin-photo.webp"`,
`loading="lazy" decoding="async"`, `loading="lazy" decoding="async"`,
`data-image-zoom-dialog`,
`<figcaption>Replacement cartridge orientation</figcaption>`, `<figcaption>Replacement cartridge orientation</figcaption>`,
`action="/posts/` + root.ID + `/edit"`, `action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`,
@@ -559,9 +556,6 @@ func TestImagePickerAssetsAreServed(t *testing.T) {
path: "/static/app.js", path: "/static/app.js",
wants: []string{ wants: []string{
`const pickerSelector = "[data-image-picker]"`, `const pickerSelector = "[data-image-picker]"`,
`video/mp4`,
`video/webm`,
`showModal`,
`new DataTransfer()`, `new DataTransfer()`,
`addEventListener("drop"`, `addEventListener("drop"`,
`resetImagePicker`, `resetImagePicker`,
@@ -575,8 +569,6 @@ func TestImagePickerAssetsAreServed(t *testing.T) {
`.image-dropzone:focus-within`, `.image-dropzone:focus-within`,
`.image-preview-list`, `.image-preview-list`,
`.post-image-grid`, `.post-image-grid`,
`.post-video`,
`.image-zoom`,
`@media (max-width: 520px)`, `@media (max-width: 520px)`,
}, },
}, },
+5 -37
View File
@@ -37,8 +37,6 @@ type Config struct {
Blob blob.Uploader Blob blob.Uploader
Events events.Publisher Events events.Publisher
BaseURL string BaseURL string
// HoldUploadUntilDiscord keeps create-post temp files until PostedToDiscord.
HoldUploadUntilDiscord bool
} }
type Server struct { type Server struct {
@@ -109,27 +107,7 @@ type threadPostCtx struct {
type imagePickerCtx struct { type imagePickerCtx struct {
ID string ID string
Video *store.PostImage Images []store.PostImage
Photos []store.PostImage
}
func splitPickerMedia(images []store.PostImage) (*store.PostImage, []store.PostImage) {
photos := make([]store.PostImage, 0, len(images))
var video *store.PostImage
for i := range images {
if images[i].Kind == store.MediaKindVideo {
img := images[i]
video = &img
continue
}
photos = append(photos, images[i])
}
return video, photos
}
func postPhotos(images []store.PostImage) []store.PostImage {
_, photos := splitPickerMedia(images)
return photos
} }
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) { func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
@@ -147,13 +125,11 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth} return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
}, },
"imagePicker": func(id string, images []store.PostImage) imagePickerCtx { "imagePicker": func(id string, images []store.PostImage) imagePickerCtx {
video, photos := splitPickerMedia(images) return imagePickerCtx{ID: id, Images: images}
return imagePickerCtx{ID: id, Video: video, Photos: photos}
}, },
"newImagePicker": func(id string) imagePickerCtx { "newImagePicker": func(id string) imagePickerCtx {
return imagePickerCtx{ID: id} return imagePickerCtx{ID: id}
}, },
"postPhotos": postPhotos,
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 }, "rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() }, "isAdmin": func(u *store.User) bool { return u.Admin() },
@@ -372,14 +348,7 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
var media []events.Media defer cleanup()
held := false
defer func() {
if !held {
closeHeldMedia(media)
cleanup()
}
}()
if !s.requireCSRF(w, r) { if !s.requireCSRF(w, r) {
return return
} }
@@ -417,7 +386,7 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
Body: body, Body: body,
City: city, City: city,
} }
images, newKeys, media, err := s.postImagesFromForm(r.Context(), r, post.ID, nil) images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil { if err != nil {
writePostImageRequestError(w, err) writePostImageRequestError(w, err)
return return
@@ -428,8 +397,7 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save question", http.StatusInternalServerError) http.Error(w, "could not save question", http.StatusInternalServerError)
return return
} }
s.publishPostCreated(post, post, u, media, cleanup) s.publishPostCreated(post, post, u)
held = true
http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther) http.Redirect(w, r, "/questions/"+url.PathEscape(post.ID), http.StatusSeeOther)
} }
+2 -4
View File
@@ -176,10 +176,8 @@ func TestRegisterLoginAsk(t *testing.T) {
`enctype="multipart/form-data"`, `enctype="multipart/form-data"`,
`data-image-picker`, `data-image-picker`,
`id="submit-images"`, `id="submit-images"`,
`accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,.mp4,.webm"`, `accept="image/jpeg,image/png,image/webp"`,
`Add up to 4 JPEG, PNG, or WebP images (5 MB each) and one MP4 or WebM video (25 MB).`, `Add up to 4 JPEG, PNG, or WebP images.`,
`Drop photos or a video here`,
`data-video-slot`,
} { } {
if !strings.Contains(rec.Body.String(), want) { if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("submit form missing %q: %s", want, rec.Body.String()) t.Fatalf("submit form missing %q: %s", want, rec.Body.String())
+3 -13
View File
@@ -48,23 +48,13 @@ CREATE TABLE IF NOT EXISTS post_images (
object_key TEXT NOT NULL UNIQUE, object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL, public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500), description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
kind TEXT NOT NULL DEFAULT 'image', position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3),
position SMALLINT NOT NULL, width INTEGER NOT NULL CHECK (width > 0),
width INTEGER NOT NULL, height INTEGER NOT NULL CHECK (height > 0),
height INTEGER NOT NULL,
created_at TEXT NOT NULL, 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) 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 ( CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
-123
View File
@@ -854,55 +854,6 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
font-size: 0.72rem; font-size: 0.72rem;
} }
.video-picker-slot:not(:empty) {
margin-bottom: 10px;
}
.video-preview {
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
border: 1px solid var(--line);
border-radius: 3px;
overflow: hidden;
background: #141516;
}
.video-preview[hidden] { display: none; }
.video-preview-media {
position: relative;
background: var(--bg);
}
.video-preview-media video,
.post-video video {
display: block;
width: 100%;
max-height: 20rem;
background: #000;
}
.post-video {
margin: 16px 0 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 3px;
background: #141516;
}
.post-video video { max-height: 32rem; }
.post-video 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;
}
.image-preview-list { .image-preview-list {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1011,18 +962,6 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
background: #141516; background: #141516;
} }
.post-image-zoom {
display: block;
color: inherit;
text-decoration: none;
cursor: zoom-in;
}
.post-image-zoom:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.post-image img { .post-image img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -1037,68 +976,6 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
object-fit: contain; object-fit: contain;
} }
.image-zoom {
width: min(100vw - 24px, 1100px);
max-width: none;
height: min(100vh - 24px, 100dvh);
max-height: none;
margin: auto;
padding: 12px;
border: 1px solid var(--line);
border-radius: 4px;
background: #101113;
color: var(--ink);
}
.image-zoom::backdrop {
background: rgba(10, 11, 12, 0.88);
}
.image-zoom-bar {
display: flex;
justify-content: flex-end;
margin: 0 0 8px;
}
.image-zoom-close {
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 3px;
background: var(--panel);
color: var(--ink);
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.image-zoom-close:hover { border-color: var(--zinc); }
.image-zoom-close:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.image-zoom img {
display: block;
width: 100%;
height: calc(100% - 4.5rem);
max-height: calc(100dvh - 8rem);
object-fit: contain;
background: #000;
}
.image-zoom-caption {
margin: 8px 0 0;
color: var(--muted);
font-family: var(--mono);
font-size: 0.72rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.post-image figcaption { .post-image figcaption {
padding: 8px 10px; padding: 8px 10px;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
+31 -171
View File
@@ -2,9 +2,7 @@
const formSelector = "form[data-submit-once]"; const formSelector = "form[data-submit-once]";
const pickerSelector = "[data-image-picker]"; const pickerSelector = "[data-image-picker]";
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]); const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
const allowedVideoTypes = new Set(["video/mp4", "video/webm"]);
const maxImageBytes = 5 * 1024 * 1024; const maxImageBytes = 5 * 1024 * 1024;
const maxVideoBytes = 25 * 1024 * 1024;
const pickerStates = new WeakMap(); const pickerStates = new WeakMap();
function progressIndicator() { function progressIndicator() {
@@ -31,20 +29,12 @@
return picker.querySelectorAll("[data-existing-image]:not([hidden])").length; return picker.querySelectorAll("[data-existing-image]:not([hidden])").length;
} }
function existingVideoCount(picker) {
return picker.querySelectorAll("[data-existing-video]:not([hidden])").length;
}
function updateImageCount(picker, state) { function updateImageCount(picker, state) {
const photos = existingImageCount(picker) + state.entries.length; const count = existingImageCount(picker) + state.entries.length;
const videos = existingVideoCount(picker) + (state.video ? 1 : 0);
const status = picker.querySelector("[data-image-count]"); const status = picker.querySelector("[data-image-count]");
if (!status) { if (status) {
return; status.textContent = `${count} of ${state.max}`;
} }
status.textContent = videos
? `${photos} of ${state.max} · 1 video`
: `${photos} of ${state.max}`;
} }
function showImageError(picker, message) { function showImageError(picker, message) {
@@ -66,16 +56,6 @@
return /\.(jpe?g|png|webp)$/i.test(file.name); return /\.(jpe?g|png|webp)$/i.test(file.name);
} }
function videoFileAllowed(file) {
if (allowedVideoTypes.has(file.type)) {
return true;
}
if (file.type) {
return false;
}
return /\.(mp4|webm)$/i.test(file.name);
}
function sameImageFile(left, right) { function sameImageFile(left, right) {
return left.name === right.name && return left.name === right.name &&
left.size === right.size && left.size === right.size &&
@@ -84,9 +64,6 @@
function syncImageInput(state) { function syncImageInput(state) {
const transfer = new DataTransfer(); const transfer = new DataTransfer();
if (state.video) {
transfer.items.add(state.video.file);
}
state.entries.forEach((entry) => transfer.items.add(entry.file)); state.entries.forEach((entry) => transfer.items.add(entry.file));
state.input.files = transfer.files; state.input.files = transfer.files;
} }
@@ -104,19 +81,36 @@
updateImageCount(picker, state); updateImageCount(picker, state);
} }
function removeNewVideo(picker, state) { function addImageFiles(picker, state, files) {
if (!state.video) { 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; return;
} }
URL.revokeObjectURL(state.video.previewURL); for (const file of uniqueFiles) {
state.video.card.remove(); if (!imageFileAllowed(file)) {
state.video = null; showImageError(picker, "Images must be JPEG, PNG, or WebP.");
syncImageInput(state); syncImageInput(state);
showImageError(picker, ""); return;
updateImageCount(picker, state); }
if (file.size > maxImageBytes) {
showImageError(picker, `${file.name} is larger than 5 MB.`);
syncImageInput(state);
return;
}
} }
function addNewImage(picker, state, file) { uniqueFiles.forEach((file) => {
const fragment = state.template.content.cloneNode(true); const fragment = state.template.content.cloneNode(true);
const card = fragment.querySelector("[data-new-image]"); const card = fragment.querySelector("[data-new-image]");
const preview = fragment.querySelector("[data-image-preview]"); const preview = fragment.querySelector("[data-image-preview]");
@@ -134,78 +128,7 @@
}); });
state.list.appendChild(fragment); state.list.appendChild(fragment);
state.entries.push(entry); state.entries.push(entry);
}
function addNewVideo(picker, state, file) {
const fragment = state.videoTemplate.content.cloneNode(true);
const card = fragment.querySelector("[data-new-video]");
const preview = fragment.querySelector("[data-video-preview]");
const name = fragment.querySelector("[data-image-name]");
const previewURL = URL.createObjectURL(file);
preview.src = previewURL;
preview.setAttribute("aria-label", file.name);
if (name) {
name.textContent = file.name;
}
const entry = { file, card, previewURL };
const removeButton = card.querySelector("[data-remove-image]");
removeButton.setAttribute("aria-label", `Remove selected video: ${file.name}`);
removeButton.addEventListener("click", () => {
removeNewVideo(picker, state);
}); });
state.videoSlot.appendChild(fragment);
state.video = entry;
}
function addImageFiles(picker, state, files) {
showImageError(picker, "");
const uniqueFiles = files.filter((file) =>
!state.entries.some((entry) => sameImageFile(entry.file, file)) &&
!(state.video && sameImageFile(state.video.file, file))
);
const videos = uniqueFiles.filter(videoFileAllowed);
const images = uniqueFiles.filter(imageFileAllowed);
if (videos.length + images.length !== uniqueFiles.length) {
showImageError(picker, "Use JPEG, PNG, or WebP photos and one MP4 or WebM video.");
syncImageInput(state);
return;
}
const availablePhotos = state.max - existingImageCount(picker) - state.entries.length;
if (images.length > availablePhotos) {
showImageError(
picker,
availablePhotos > 0
? `You can add ${availablePhotos} more ${availablePhotos === 1 ? "image" : "images"}.`
: "You already have 4 images selected."
);
syncImageInput(state);
return;
}
const availableVideos = state.maxVideos - existingVideoCount(picker) - (state.video ? 1 : 0);
if (videos.length > availableVideos) {
showImageError(
picker,
availableVideos > 0 ? "You can attach one video." : "You already have a video selected."
);
syncImageInput(state);
return;
}
for (const file of images) {
if (file.size > maxImageBytes) {
showImageError(picker, `${file.name} is larger than 5 MB.`);
syncImageInput(state);
return;
}
}
for (const file of videos) {
if (file.size > maxVideoBytes) {
showImageError(picker, `${file.name} is larger than 25 MB.`);
syncImageInput(state);
return;
}
}
videos.forEach((file) => addNewVideo(picker, state, file));
images.forEach((file) => addNewImage(picker, state, file));
syncImageInput(state); syncImageInput(state);
updateImageCount(picker, state); updateImageCount(picker, state);
} }
@@ -220,13 +143,8 @@
entry.card.remove(); entry.card.remove();
}); });
state.entries = []; state.entries = [];
if (state.video) {
URL.revokeObjectURL(state.video.previewURL);
state.video.card.remove();
state.video = null;
}
state.input.value = ""; state.input.value = "";
picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => { picker.querySelectorAll("[data-existing-image]").forEach((card) => {
card.hidden = false; card.hidden = false;
card.querySelectorAll("input").forEach((input) => { card.querySelectorAll("input").forEach((input) => {
input.disabled = false; input.disabled = false;
@@ -248,21 +166,15 @@
const dropzone = picker.querySelector("[data-image-dropzone]"); const dropzone = picker.querySelector("[data-image-dropzone]");
const list = picker.querySelector("[data-image-list]"); const list = picker.querySelector("[data-image-list]");
const template = picker.querySelector("[data-image-template]"); const template = picker.querySelector("[data-image-template]");
const videoSlot = picker.querySelector("[data-video-slot]"); if (!input || !dropzone || !list || !template) {
const videoTemplate = picker.querySelector("[data-video-template]");
if (!input || !dropzone || !list || !template || !videoSlot || !videoTemplate) {
return; return;
} }
const state = { const state = {
input, input,
list, list,
template, template,
videoSlot,
videoTemplate,
entries: [], entries: [],
video: null,
max: Number.parseInt(picker.dataset.maxImages, 10) || 4, max: Number.parseInt(picker.dataset.maxImages, 10) || 4,
maxVideos: Number.parseInt(picker.dataset.maxVideos, 10) || 1,
}; };
pickerStates.set(picker, state); pickerStates.set(picker, state);
updateImageCount(picker, state); updateImageCount(picker, state);
@@ -270,7 +182,7 @@
input.addEventListener("change", () => { input.addEventListener("change", () => {
addImageFiles(picker, state, Array.from(input.files)); addImageFiles(picker, state, Array.from(input.files));
}); });
picker.querySelectorAll("[data-existing-image], [data-existing-video]").forEach((card) => { picker.querySelectorAll("[data-existing-image]").forEach((card) => {
card.querySelector("[data-remove-image]").addEventListener("click", () => { card.querySelector("[data-remove-image]").addEventListener("click", () => {
card.hidden = true; card.hidden = true;
card.querySelectorAll("input").forEach((existingInput) => { card.querySelectorAll("input").forEach((existingInput) => {
@@ -330,9 +242,6 @@
window.addEventListener("pageshow", () => { window.addEventListener("pageshow", () => {
document.querySelectorAll(formSelector).forEach(resetForm); document.querySelectorAll(formSelector).forEach(resetForm);
document.querySelectorAll(pickerSelector).forEach(resetImagePicker); document.querySelectorAll(pickerSelector).forEach(resetImagePicker);
if (zoomDialog?.open) {
zoomDialog.close();
}
const progress = progressIndicator(); const progress = progressIndicator();
if (progress) { if (progress) {
progress.hidden = true; progress.hidden = true;
@@ -340,53 +249,4 @@
}); });
document.querySelectorAll(pickerSelector).forEach(initializeImagePicker); document.querySelectorAll(pickerSelector).forEach(initializeImagePicker);
const zoomDialog = document.querySelector("[data-image-zoom-dialog]");
const zoomImage = zoomDialog?.querySelector("[data-image-zoom-img]");
const zoomCaption = zoomDialog?.querySelector("[data-image-zoom-caption]");
let zoomTrigger = null;
function openImageZoom(link) {
if (!zoomDialog || !zoomImage || typeof zoomDialog.showModal !== "function") {
return false;
}
const photo = link.querySelector("img");
if (!photo) {
return false;
}
zoomTrigger = link;
zoomImage.src = link.href;
zoomImage.alt = photo.alt || "";
if (zoomCaption) {
zoomCaption.textContent = photo.alt || "";
}
if (!zoomDialog.open) {
zoomDialog.showModal();
}
return true;
}
document.addEventListener("click", (event) => {
const link = event.target.closest("[data-image-zoom]");
if (!link) {
return;
}
if (openImageZoom(link)) {
event.preventDefault();
}
});
zoomDialog?.addEventListener("click", (event) => {
if (event.target === zoomDialog) {
zoomDialog.close();
}
});
zoomDialog?.addEventListener("close", () => {
if (zoomImage) {
zoomImage.removeAttribute("src");
}
zoomTrigger?.focus();
zoomTrigger = null;
});
})(); })();
-7
View File
@@ -73,13 +73,6 @@
<footer class="site-footer"> <footer class="site-footer">
<p>This site is not a substitute for a licensed plumber. Advice is general and based on the question as written. If you have a gas leak, flooding, or another emergency, leave the area if needed and call 911.</p> <p>This site is not a substitute for a licensed plumber. Advice is general and based on the question as written. If you have a gas leak, flooding, or another emergency, leave the area if needed and call 911.</p>
</footer> </footer>
<dialog class="image-zoom" data-image-zoom-dialog aria-labelledby="image-zoom-caption">
<form method="dialog" class="image-zoom-bar">
<button class="image-zoom-close" value="close">Close</button>
</form>
<img data-image-zoom-img alt="">
<p id="image-zoom-caption" class="image-zoom-caption" data-image-zoom-caption></p>
</dialog>
</body> </body>
</html> </html>
{{end}} {{end}}
+9 -65
View File
@@ -1,50 +1,26 @@
{{define "imagePicker"}} {{define "imagePicker"}}
<fieldset class="image-picker" data-image-picker data-max-images="4" data-max-videos="1"> <fieldset class="image-picker" data-image-picker data-max-images="4">
<legend>Photos and video <span class="optional">(optional)</span></legend> <legend>Photos <span class="optional">(optional)</span></legend>
<p id="{{.ID}}-hint" class="image-picker-hint"> <p id="{{.ID}}-hint" class="image-picker-hint">
Add up to 4 JPEG, PNG, or WebP images (5 MB each) and one MP4 or WebM video (25 MB). Add up to 4 JPEG, PNG, or WebP images. Each image can be up to 5 MB.
</p> </p>
<div class="video-picker-slot" data-video-slot>
{{if .Video}}
<article class="video-preview" data-video-card data-existing-video>
<div class="video-preview-media">
<video src="{{.Video.PublicURL}}" controls playsinline preload="metadata"
{{if .Video.Description}}aria-label="{{.Video.Description}}"{{else}}aria-label="Saved video"{{end}}></video>
<span class="image-preview-tag">Saved</span>
</div>
<div class="image-preview-fields">
<input type="hidden" name="existing_image_id" value="{{.Video.ID}}">
<label for="{{.ID}}-video-description-{{.Video.ID}}">
Video description <span class="optional">(optional)</span>
</label>
<input id="{{.ID}}-video-description-{{.Video.ID}}" type="text"
name="existing_image_description" maxlength="500"
value="{{.Video.Description}}" placeholder="What should people notice?">
<button class="image-remove" type="button" data-remove-image
aria-label="Remove video{{if .Video.Description}}: {{.Video.Description}}{{end}}">
Remove
</button>
</div>
</article>
{{end}}
</div>
<div class="image-dropzone" data-image-dropzone> <div class="image-dropzone" data-image-dropzone>
<input id="{{.ID}}" class="image-input" type="file" name="images" <input id="{{.ID}}" class="image-input" type="file" name="images"
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,.mp4,.webm" multiple accept="image/jpeg,image/png,image/webp" multiple
aria-describedby="{{.ID}}-hint {{.ID}}-status {{.ID}}-error" aria-describedby="{{.ID}}-hint {{.ID}}-status {{.ID}}-error"
data-image-input> data-image-input>
<label class="image-dropzone-label" for="{{.ID}}"> <label class="image-dropzone-label" for="{{.ID}}">
<strong>Drop photos or a video here</strong> <strong>Drop photos here</strong>
<span>or click to browse</span> <span>or click to browse</span>
</label> </label>
<span id="{{.ID}}-status" class="image-picker-count" role="status" <span id="{{.ID}}-status" class="image-picker-count" role="status"
aria-live="polite" data-image-count>{{len .Photos}} of 4{{if .Video}} · 1 video{{end}}</span> aria-live="polite" data-image-count>{{len .Images}} of 4</span>
</div> </div>
<p id="{{.ID}}-error" class="image-picker-error" role="alert" <p id="{{.ID}}-error" class="image-picker-error" role="alert"
data-image-error hidden></p> data-image-error hidden></p>
<div class="image-preview-list" data-image-list> <div class="image-preview-list" data-image-list>
{{range .Photos}} {{range .Images}}
<article class="image-preview" data-image-card data-existing-image> <article class="image-preview" data-image-card data-existing-image>
<div class="image-preview-media"> <div class="image-preview-media">
<img src="{{.PublicURL}}" alt="" width="{{.Width}}" height="{{.Height}}"> <img src="{{.PublicURL}}" alt="" width="{{.Width}}" height="{{.Height}}">
@@ -87,26 +63,6 @@
</div> </div>
</article> </article>
</template> </template>
<template data-video-template>
<article class="video-preview" data-video-card data-new-video>
<div class="video-preview-media">
<video controls playsinline preload="metadata" data-video-preview></video>
<span class="image-preview-tag">New</span>
</div>
<div class="image-preview-fields">
<p class="image-preview-name" data-image-name></p>
<label>
Video 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 video">
Remove
</button>
</div>
</article>
</template>
<noscript> <noscript>
<p class="image-picker-hint">Image previews and removal while editing require JavaScript.</p> <p class="image-picker-hint">Image previews and removal while editing require JavaScript.</p>
</noscript> </noscript>
@@ -114,25 +70,13 @@
{{end}} {{end}}
{{define "postImages"}} {{define "postImages"}}
{{if .Images}}
<div class="post-image-grid post-image-grid-{{len .Images}}">
{{range .Images}} {{range .Images}}
{{if eq .Kind "video"}}
<figure class="post-video">
<video src="{{.PublicURL}}" controls playsinline preload="metadata"
{{if .Description}}aria-label="{{.Description}}"{{else}}aria-label="Video attached to this post"{{end}}></video>
{{if .Description}}<figcaption>{{.Description}}</figcaption>{{end}}
</figure>
{{end}}
{{end}}
{{$photos := postPhotos .Images}}
{{if $photos}}
<div class="post-image-grid post-image-grid-{{len $photos}}">
{{range $photos}}
<figure class="post-image"> <figure class="post-image">
<a class="post-image-zoom" href="{{.PublicURL}}" data-image-zoom>
<img src="{{.PublicURL}}" width="{{.Width}}" height="{{.Height}}" <img src="{{.PublicURL}}" width="{{.Width}}" height="{{.Height}}"
alt="{{if .Description}}{{.Description}}{{else}}Photo attached to this post{{end}}" alt="{{if .Description}}{{.Description}}{{else}}Photo attached to this post{{end}}"
loading="lazy" decoding="async"> loading="lazy" decoding="async">
</a>
{{if .Description}}<figcaption>{{.Description}}</figcaption>{{end}} {{if .Description}}<figcaption>{{.Description}}</figcaption>{{end}}
</figure> </figure>
{{end}} {{end}}