Compare commits

...
Author SHA1 Message Date
codegirl007 d7bfb93de1 Add post video upload.
CI / test (pull_request) Successful in 6m24s
2026-08-30 21:39:29 -07:00
codegirl007 728ae68811 Add post video storage (#21)
CI / test (push) Successful in 6m28s
post_images.kind is image or video. A post can keep one video alongside up to four images.

Reviewed-on: #21
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-31 04:37:08 +00:00
codegirl007 2eb8bff1c4 Add post image upload (#20)
CI / test (push) Successful in 6m28s
Lands the image upload handlers and picker on master. Questions and replies can attach up to four JPEG, PNG, or WebP photos.

Reviewed-on: #20
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-31 04:29:51 +00:00
codegirl007 e68afba6a5 Publish Discord inbound replies on the post event bus (#19)
CI / test (push) Successful in 6m26s
Inbound publishes PostCreated after saving the Discord link. Outbound skips already-linked posts so the bot does not echo. Mail uses the existing subscriber.

Reviewed-on: #19
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-31 04:27:11 +00:00
29 changed files with 2051 additions and 81 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ func main() {
}
bus := events.New()
mail.Subscribe(bus, store.NewPostgres(db), notifier)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db), notifier)
bot, err := discord.FromEnv(store.NewDiscordLinks(db), bus, store.NewPostgres(db))
if err != nil {
log.Fatalf("discord: %v", err)
}
+4 -3
View File
@@ -63,7 +63,7 @@ WHERE id = sqlc.arg(id);
-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
)
VALUES (
sqlc.arg(id),
@@ -71,6 +71,7 @@ VALUES (
sqlc.arg(object_key),
sqlc.arg(public_url),
sqlc.arg(description),
sqlc.arg(kind),
sqlc.arg(position),
sqlc.arg(width),
sqlc.arg(height),
@@ -83,7 +84,7 @@ WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
FROM post_images
WHERE post_id = sqlc.arg(post_id)
ORDER BY position;
@@ -102,7 +103,7 @@ WITH RECURSIVE thread AS (
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at
images.description, images.kind, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position;
+1
View File
@@ -13,6 +13,7 @@ require (
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/resend/resend-go/v3 v3.16.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
)
+2
View File
@@ -49,6 +49,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/resend/resend-go/v3 v3.16.0 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE=
github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+11 -7
View File
@@ -13,7 +13,6 @@ import (
"github.com/bwmarrin/discordgo"
"plumber/internal/events"
"plumber/internal/mail"
"plumber/internal/store"
)
@@ -25,7 +24,7 @@ type Bot struct {
links store.DiscordLinkStore
api API
store store.Store
mail mail.Notifier
bus events.Publisher
admins map[string]string
botUserID string
}
@@ -36,7 +35,7 @@ func New(channelID string, links store.DiscordLinkStore, api API) *Bot {
}
// FromEnv builds a bot when Discord env is set. Missing config is a no-op.
func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store, mailer mail.Notifier) (*Bot, error) {
func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store) (*Bot, error) {
token := strings.TrimSpace(os.Getenv("DISCORD_BOT_TOKEN"))
channelID := strings.TrimSpace(os.Getenv("DISCORD_CHANNEL_ID"))
if token == "" && channelID == "" {
@@ -56,12 +55,9 @@ func FromEnv(links store.DiscordLinkStore, bus *events.Bus, st store.Store, mail
return nil, err
}
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent
if mailer == nil {
mailer = mail.Nop{}
}
bot := New(channelID, links, &sessionAPI{session: session})
bot.store = st
bot.mail = mailer
bot.bus = bus
bot.admins = parseAdminMap(os.Getenv("DISCORD_ADMIN_MAP"))
session.AddHandler(bot.onMessageCreate)
if bus != nil {
@@ -102,6 +98,14 @@ func (b *Bot) Handle(_ context.Context, ev any) {
}
func (b *Bot) onCreated(ctx context.Context, ev events.PostEvent) {
_, err := b.links.GetByPostID(ctx, ev.PostID)
if err == nil {
return
}
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("discord: load link %s: %v", ev.PostID, err)
return
}
if isRoot(ev) {
b.createRoot(ctx, ev)
return
+33 -3
View File
@@ -161,6 +161,36 @@ func TestOutboundSkipsReplyWithoutRootLink(t *testing.T) {
}
}
func TestOutboundSkipsAlreadyLinkedPost(t *testing.T) {
t.Parallel()
links := newMemoryLinks()
api := &fakeAPI{}
bot := New("channel-1", links, api)
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "reply-1",
MessageID: "d-reply-1",
}); err != nil {
t.Fatal(err)
}
if err := links.Upsert(context.Background(), store.DiscordLink{
PostID: "root-1",
MessageID: "d-root",
ThreadID: "thread-1",
}); err != nil {
t.Fatal(err)
}
bot.Handle(context.Background(), events.PostCreated{PostEvent: events.PostEvent{
PostID: "reply-1",
RootID: "root-1",
ParentID: "root-1",
Body: "Already on Discord.",
}})
if len(api.sends) != 0 {
t.Fatalf("echoed already-linked reply: %+v", api.sends)
}
}
func TestOutboundUpdateWithoutLinkCreates(t *testing.T) {
t.Parallel()
@@ -218,7 +248,7 @@ func TestFormatMessage(t *testing.T) {
func TestFromEnvDisabled(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "")
bot, err := FromEnv(newMemoryLinks(), nil, nil, nil)
bot, err := FromEnv(newMemoryLinks(), nil, nil)
if err != nil || bot != nil {
t.Fatalf("disabled FromEnv = (%v, %v)", bot, err)
}
@@ -227,12 +257,12 @@ func TestFromEnvDisabled(t *testing.T) {
func TestFromEnvRequiresBoth(t *testing.T) {
t.Setenv("DISCORD_BOT_TOKEN", "token")
t.Setenv("DISCORD_CHANNEL_ID", "")
if _, err := FromEnv(newMemoryLinks(), nil, nil, nil); err == nil {
if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil {
t.Fatal("expected error when channel is missing")
}
t.Setenv("DISCORD_BOT_TOKEN", "")
t.Setenv("DISCORD_CHANNEL_ID", "channel")
if _, err := FromEnv(newMemoryLinks(), nil, nil, nil); err == nil {
if _, err := FromEnv(newMemoryLinks(), nil, nil); err == nil {
t.Fatal("expected error when token is missing")
}
}
+14 -38
View File
@@ -7,11 +7,10 @@ import (
"fmt"
"log"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"plumber/internal/mail"
"plumber/internal/events"
"plumber/internal/store"
)
@@ -127,8 +126,9 @@ func (b *Bot) handleInbound(in inboundMessage) {
MessageID: in.ID,
}); err != nil {
log.Printf("discord: save inbound link %s: %v", reply.ID, err)
return
}
b.notifyInboundReply(parent, root, reply, author)
b.publishInbound(reply, root, author)
log.Printf("discord: inbound reply %s -> post %s", in.ID, reply.ID)
}
@@ -184,44 +184,20 @@ func (b *Bot) postAndRoot(ctx context.Context, postID string) (*store.Post, *sto
return post, current, nil
}
func (b *Bot) notifyInboundReply(parent, root, reply *store.Post, author *store.User) {
if parent == nil || root == nil || reply == nil || author == nil || b.mail == nil {
func (b *Bot) publishInbound(reply, root *store.Post, author *store.User) {
if b == nil || b.bus == nil || reply == nil || root == nil || author == nil {
return
}
if _, disabled := b.mail.(mail.Nop); disabled {
return
}
recipientID := parent.AuthorID
if author.Admin() {
recipientID = root.AuthorID
}
if recipientID == author.ID {
return
}
msg := mail.PostReply{
ev := events.PostEvent{
PostID: reply.ID,
RootID: root.ID,
RootTitle: root.Title,
ReplyID: reply.ID,
ReplyBody: reply.Body,
ReplyAuthorName: author.Name,
Body: reply.Body,
AuthorID: author.ID,
AuthorName: author.Name,
AuthorRole: string(author.Role),
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
recipient, err := b.store.UserByID(ctx, recipientID)
if err != nil {
log.Printf("notify reply %s: load recipient: %v", msg.ReplyID, err)
return
if reply.ParentID != nil {
ev.ParentID = *reply.ParentID
}
if recipient == nil || strings.TrimSpace(recipient.Email) == "" {
return
}
msg.ToEmail = recipient.Email
msg.ToName = recipient.Name
if err := b.mail.NotifyPostReply(ctx, msg); err != nil {
log.Printf("notify reply %s: %v", msg.ReplyID, err)
return
}
log.Printf("notify reply %s: accepted", msg.ReplyID)
}()
b.bus.Publish(context.Background(), events.PostCreated{PostEvent: ev})
}
+6 -5
View File
@@ -27,9 +27,11 @@ func TestInboundCreatesSiteReply(t *testing.T) {
links := newMemoryLinks()
api := &fakeAPI{}
mailer := &mail.Recording{}
bot := inboundTestBot(mem, links, api, mailer, admin.Username)
bot := inboundTestBot(mem, links, api, admin.Username)
bus := events.New()
defer bus.Close()
bot.bus = bus
mail.Subscribe(bus, mem, mailer)
bus.Subscribe(bot.Handle)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
@@ -68,7 +70,7 @@ func TestInboundParentsFromReference(t *testing.T) {
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
bot := inboundTestBot(mem, links, &fakeAPI{}, &mail.Recording{}, admin.Username)
bot := inboundTestBot(mem, links, &fakeAPI{}, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
plumberReply := &store.Post{ParentID: &root.ID, AuthorID: admin.ID, Body: "First look."}
if err := mem.CreatePost(context.Background(), plumberReply); err != nil {
@@ -106,7 +108,7 @@ func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
mem, homeowner, admin := seedInboundUsers(t)
links := newMemoryLinks()
api := &fakeAPI{}
bot := inboundTestBot(mem, links, api, mail.Nop{}, admin.Username)
bot := inboundTestBot(mem, links, api, admin.Username)
root := seedLinkedRoot(t, mem, links, homeowner.ID, "thread-1")
hidden := &store.Post{
AuthorID: homeowner.ID,
@@ -148,10 +150,9 @@ func TestInboundIgnoresAllowlistHiddenAndEchoSources(t *testing.T) {
}
}
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, mailer mail.Notifier, adminUsername string) *Bot {
func inboundTestBot(mem *store.Memory, links *memoryLinks, api *fakeAPI, adminUsername string) *Bot {
bot := New("channel-1", links, api)
bot.store = mem
bot.mail = mailer
bot.admins = map[string]string{"snow-admin": adminUsername}
bot.botUserID = "bot-1"
return bot
+27
View File
@@ -158,6 +158,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
return nil
}
func migratePostImageVideo(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"add kind", `ALTER TABLE post_images ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'image'`},
{"drop kind check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_kind_check`},
{"add kind check", `ALTER TABLE post_images ADD CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video'))`},
{"drop width check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_width_check`},
{"drop height check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_height_check`},
{"add width check", `ALTER TABLE post_images ADD CONSTRAINT post_images_width_check CHECK (width >= 0)`},
{"add height check", `ALTER TABLE post_images ADD CONSTRAINT post_images_height_check CHECK (height >= 0)`},
{"drop image dims check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_image_dims_check`},
{"add image dims check", `ALTER TABLE post_images ADD CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0))`},
{"drop position check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_position_check`},
{"add position check", `ALTER TABLE post_images ADD CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4)`},
{"one video index", `CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx ON post_images (post_id) WHERE kind = 'video'`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -351,6 +377,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks},
{"012_post_image_video", migratePostImageVideo},
}
for _, m := range migrations {
if applied[m.version] {
+25 -5
View File
@@ -87,6 +87,12 @@ CREATE TABLE users (
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err)
}
if err := migratePostImageVideo(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostImageVideo(ctx, conn); err != nil {
t.Fatalf("post image video migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
@@ -118,9 +124,9 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
}
imageQueries := sqlc.New(conn)
for _, image := range []sqlc.CreatePostImageParams{
{ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"},
{ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Kind: "image", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Kind: "image", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Kind: "image", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"},
} {
if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err)
@@ -144,10 +150,24 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg",
PublicUrl: "https://cdn.example/root-5.jpg", Position: 4,
PublicUrl: "https://cdn.example/root-5.jpg", Kind: "image", Position: 5,
Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("fifth image position unexpectedly succeeded")
t.Fatal("position 5 unexpectedly succeeded")
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "root-video-1", PostID: "root-1", ObjectKey: "posts/root-1/clip.mp4",
PublicUrl: "https://cdn.example/clip.mp4", Kind: "video", Position: 2,
Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z",
}); err != nil {
t.Fatal(err)
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "root-video-2", PostID: "root-1", ObjectKey: "posts/root-1/clip-2.mp4",
PublicUrl: "https://cdn.example/clip-2.mp4", Kind: "video", Position: 3,
Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("second video unexpectedly succeeded")
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1",
+32 -3
View File
@@ -29,16 +29,20 @@ const (
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
MaxPostImages = 4
MaxPostVideos = 1
MaxImageDescriptionRunes = 500
MediaKindImage = "image"
MediaKindVideo = "video"
)
// PostImage is one ordered public image attached to a post.
// PostImage is one ordered public image or video attached to a post.
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicURL string
Description string
Kind string
Position int
Width int
Height int
@@ -200,12 +204,13 @@ func preparePost(p *Post) error {
}
func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
if len(p.Images) > MaxPostImages+MaxPostVideos {
return fmt.Errorf("%w: at most %d images and %d video are allowed", ErrInvalidPost, MaxPostImages, MaxPostVideos)
}
ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano)
images, videos := 0, 0
for i := range p.Images {
image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID)
@@ -213,6 +218,13 @@ func preparePostImages(p *Post) error {
image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description)
image.Kind = strings.TrimSpace(image.Kind)
if image.Kind == "" {
image.Kind = MediaKindImage
}
if image.Kind != MediaKindImage && image.Kind != MediaKindVideo {
return fmt.Errorf("%w: invalid media kind", ErrInvalidPost)
}
if image.ID == "" {
image.ID = uuid.NewString()
}
@@ -228,10 +240,19 @@ func preparePostImages(p *Post) error {
if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
}
if image.Kind == MediaKindImage {
images++
if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost)
}
} else {
videos++
if image.Width < 0 || image.Height < 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid video dimensions", ErrInvalidPost)
}
}
if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
}
@@ -242,6 +263,12 @@ func preparePostImages(p *Post) error {
image.CreatedAt = now
}
}
if images > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
if videos > MaxPostVideos {
return fmt.Errorf("%w: at most %d video is allowed", ErrInvalidPost, MaxPostVideos)
}
return nil
}
@@ -253,6 +280,7 @@ func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage)
ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL,
Description: image.Description,
Kind: image.Kind,
Position: int16(image.Position),
Width: int32(image.Width),
Height: int32(image.Height),
@@ -271,6 +299,7 @@ func postImageFromSQL(image sqlc.PostImage) PostImage {
ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl,
Description: image.Description,
Kind: image.Kind,
Position: int(image.Position),
Width: int(image.Width),
Height: int(image.Height),
+30
View File
@@ -338,6 +338,36 @@ func TestMemoryPostImages(t *testing.T) {
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
}
withVideo := &Post{
AuthorID: homeowner.ID,
Title: "With video",
Body: "Four photos and a clip.",
Images: append(validPostImages(4), PostImage{
ID: "clip-1",
ObjectKey: "posts/clip-1.mp4",
PublicURL: "https://cdn.example/clip-1.mp4",
Kind: MediaKindVideo,
}),
}
if err := mem.CreatePost(ctx, withVideo); err != nil {
t.Fatalf("four images and one video: %v", err)
}
if withVideo.Images[4].Kind != MediaKindVideo || withVideo.Images[4].Position != 4 {
t.Fatalf("video not stored: %+v", withVideo.Images[4])
}
twoVideos := &Post{
AuthorID: homeowner.ID,
Title: "Two clips",
Body: "Not allowed.",
Images: []PostImage{
{ObjectKey: "posts/a.mp4", PublicURL: "https://cdn.example/a.mp4", Kind: MediaKindVideo},
{ObjectKey: "posts/b.mp4", PublicURL: "https://cdn.example/b.mp4", Kind: MediaKindVideo},
},
}
if err := mem.CreatePost(ctx, twoVideos); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("two-video create error = %v, want ErrInvalidPost", err)
}
}
func validPostImages(count int) []PostImage {
+1
View File
@@ -35,6 +35,7 @@ type PostImage struct {
ObjectKey string
PublicUrl string
Description string
Kind string
Position int16
Width int32
Height int32
+9 -4
View File
@@ -59,7 +59,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
)
VALUES (
$1,
@@ -70,7 +70,8 @@ VALUES (
$6,
$7,
$8,
$9
$9,
$10
)
`
@@ -80,6 +81,7 @@ type CreatePostImageParams struct {
ObjectKey string
PublicUrl string
Description string
Kind string
Position int16
Width int32
Height int32
@@ -93,6 +95,7 @@ func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams
arg.ObjectKey,
arg.PublicUrl,
arg.Description,
arg.Kind,
arg.Position,
arg.Width,
arg.Height,
@@ -201,7 +204,7 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
const listPostImages = `-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
id, post_id, object_key, public_url, description, kind, position, width, height, created_at
FROM post_images
WHERE post_id = $1
ORDER BY position
@@ -222,6 +225,7 @@ func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImag
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Kind,
&i.Position,
&i.Width,
&i.Height,
@@ -327,7 +331,7 @@ WITH RECURSIVE thread AS (
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at
images.description, images.kind, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position
@@ -348,6 +352,7 @@ func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]Po
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Kind,
&i.Position,
&i.Width,
&i.Height,
+3
View File
@@ -55,6 +55,9 @@ func (s *Server) postEvent(post, root *store.Post, author *store.User) events.Po
if n := len(post.Images); n > 0 {
ev.Images = make([]events.Image, 0, n)
for _, img := range post.Images {
if img.Kind == store.MediaKindVideo {
continue
}
ev.Images = append(ev.Images, events.Image{
URL: img.PublicURL,
Description: img.Description,
+18
View File
@@ -236,3 +236,21 @@ func assertPostEvent(t *testing.T, got, want events.PostEvent) {
t.Fatalf("event = %+v, want %+v", got, want)
}
}
func TestPostEventOmitsVideos(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, Config{})
got := srv.postEvent(&store.Post{
ID: "root-1",
Title: "Clip",
Body: "Photo and video.",
Images: []store.PostImage{
{PublicURL: "https://cdn.example/a.jpg", Description: "Still", Kind: store.MediaKindImage},
{PublicURL: "https://cdn.example/a.mp4", Description: "Walkthrough", Kind: store.MediaKindVideo},
},
}, nil, nil)
if len(got.Images) != 1 || got.Images[0].URL != "https://cdn.example/a.jpg" {
t.Fatalf("event images = %+v", got.Images)
}
}
+551
View File
@@ -0,0 +1,551 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"plumber/internal/blob"
"plumber/internal/store"
)
const (
defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20
postVideoMaxFileBytes = 25 << 20
postImageMaxRequestBytes = 50 << 20 // 4 images + 1 video + form fields
postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000
postImageMaxRenderedDim = 1600
postImageCleanupTimeout = 10 * time.Second
)
func requestBodyLimit(r *http.Request) int64 {
if r.Method != http.MethodPost {
return defaultRequestBodyBytes
}
switch {
case r.URL.Path == "/submit", r.URL.Path == "/posts":
return postImageMaxRequestBytes
case strings.HasPrefix(r.URL.Path, "/posts/") && strings.HasSuffix(r.URL.Path, "/edit"):
return postImageMaxRequestBytes
default:
return defaultRequestBodyBytes
}
}
type postImageRequestError struct {
status int
message string
cause error
}
func (e *postImageRequestError) Error() string {
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func parsePostMutationForm(w http.ResponseWriter, r *http.Request) (func(), bool) {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
http.Error(w, "Could not read upload.", http.StatusBadRequest)
return func() {}, false
}
if mediaType != "multipart/form-data" {
if err := r.ParseForm(); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
return func() {}, true
}
if err := r.ParseMultipartForm(postImageMultipartMemory); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
cleanup := func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}
return cleanup, true
}
func writePostImageRequestError(w http.ResponseWriter, err error) {
var requestErr *postImageRequestError
if errors.As(err, &requestErr) {
http.Error(w, requestErr.message, requestErr.status)
return
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "Upload is too large.", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "Could not read upload.", http.StatusBadRequest)
}
func (s *Server) postImagesFromForm(
ctx context.Context,
r *http.Request,
postID string,
existing []store.PostImage,
) ([]store.PostImage, []string, error) {
if r.MultipartForm == nil {
return append([]store.PostImage(nil), existing...), nil, nil
}
retained, err := retainedPostImages(r.MultipartForm, existing)
if err != nil {
return nil, nil, err
}
files := r.MultipartForm.File["images"]
descriptions := r.MultipartForm.Value["image_description"]
if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
}
kinds := make([]string, len(files))
newImages, newVideos := 0, 0
for i, header := range files {
kinds[i] = sniffPostMedia(header)
if kinds[i] == store.MediaKindVideo {
newVideos++
} else {
newImages++
}
}
retainedImages, retainedVideos := countPostMedia(retained)
if retainedImages+newImages > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if retainedVideos+newVideos > store.MaxPostVideos {
return nil, nil, invalidPostImage("You can attach one video.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Image uploads are not configured on this server.",
}
}
images := append([]store.PostImage(nil), retained...)
newKeys := make([]string, 0, len(files))
for i, header := range files {
description := ""
if i < len(descriptions) {
description = strings.TrimSpace(descriptions[i])
}
if len([]rune(description)) > store.MaxImageDescriptionRunes {
s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
item, objectKey, err := s.uploadPostMedia(ctx, postID, header, kinds[i], description)
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, err
}
newKeys = append(newKeys, objectKey)
images = append(images, item)
}
return images, newKeys, nil
}
func countPostMedia(items []store.PostImage) (images, videos int) {
for _, item := range items {
if item.Kind == store.MediaKindVideo {
videos++
} else {
images++
}
}
return images, videos
}
func (s *Server) uploadPostMedia(
ctx context.Context,
postID string,
header *multipart.FileHeader,
kind, description string,
) (store.PostImage, string, error) {
if kind == store.MediaKindVideo {
prepared, err := preparePostVideo(header)
if err != nil {
return store.PostImage{}, "", err
}
mediaID := uuid.NewString()
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
ContentType: prepared.contentType,
Size: int64(len(prepared.body)),
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload video. Try again later.",
cause: err,
}
}
return store.PostImage{
ID: mediaID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindVideo,
}, objectKey, nil
}
prepared, err := preparePostImage(header)
if err != nil {
return store.PostImage{}, "", err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
ContentType: prepared.contentType,
Size: int64(len(prepared.body)),
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
return store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindImage,
Width: prepared.width,
Height: prepared.height,
}, objectKey, nil
}
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
byID := make(map[string]store.PostImage, len(existing))
for _, image := range existing {
byID[image.ID] = image
}
ids := form.Value["existing_image_id"]
descriptions := form.Value["existing_image_description"]
if len(descriptions) > len(ids) {
return nil, invalidPostImage("Existing image descriptions do not match the images.", nil)
}
seen := make(map[string]bool, len(ids))
retained := make([]store.PostImage, 0, len(ids))
for i, id := range ids {
id = strings.TrimSpace(id)
image, ok := byID[id]
if !ok || seen[id] {
return nil, invalidPostImage("An existing image selection is invalid.", nil)
}
seen[id] = true
if i < len(descriptions) {
image.Description = strings.TrimSpace(descriptions[i])
}
if len([]rune(image.Description)) > store.MaxImageDescriptionRunes {
return nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
retained = append(retained, image)
}
return retained, nil
}
func invalidPostImage(message string, cause error) error {
return &postImageRequestError{status: http.StatusBadRequest, message: message, cause: cause}
}
type preparedPostImage struct {
body []byte
extension string
contentType string
width int
height int
}
func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
if header == nil {
return preparedPostImage{}, invalidPostImage("Select a valid image.", nil)
}
if header.Size > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postImageMaxFileBytes+1))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
if len(raw) == 0 {
return preparedPostImage{}, invalidPostImage("Images cannot be empty.", nil)
}
if int64(len(raw)) > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
sniffed := http.DetectContentType(raw)
switch sniffed {
case "image/jpeg", "image/png", "image/webp":
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > postImageMaxSourceDim || cfg.Height > postImageMaxSourceDim ||
int64(cfg.Width)*int64(cfg.Height) > postImageMaxSourcePixels {
return preparedPostImage{}, invalidPostImage("Image dimensions are too large.", nil)
}
decoded, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if format != "" {
decodedFormat = format
}
if sniffed == "image/jpeg" {
decoded = orientPostImage(decoded, jpegOrientation(raw))
}
decoded = fitPostImage(decoded, postImageMaxRenderedDim)
var out bytes.Buffer
result := preparedPostImage{}
switch decodedFormat {
case "jpeg":
if err := jpeg.Encode(&out, decoded, &jpeg.Options{Quality: 85}); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".jpg"
result.contentType = "image/jpeg"
case "png", "webp":
if err := png.Encode(&out, decoded); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".png"
result.contentType = "image/png"
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
result.body = out.Bytes()
result.width = decoded.Bounds().Dx()
result.height = decoded.Bounds().Dy()
return result, nil
}
func 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 []byte
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 > 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)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postVideoMaxFileBytes+1))
if err != nil {
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
if len(raw) == 0 {
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
}
if int64(len(raw)) > postVideoMaxFileBytes {
return preparedPostVideo{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each video must be 25 MB or smaller.",
}
}
switch mediaKindFromBytes(raw) {
case store.MediaKindVideo:
default:
return preparedPostVideo{}, invalidPostImage("Videos must be MP4 or WebM.", nil)
}
result := preparedPostVideo{body: raw}
if isWebM(raw) {
result.extension = ".webm"
result.contentType = "video/webm"
return result, nil
}
result.extension = ".mp4"
result.contentType = "video/mp4"
return result, nil
}
func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil {
return 1
}
tag, err := metadata.Get(exif.Orientation)
if err != nil {
return 1
}
orientation, err := tag.Int(0)
if err != nil || orientation < 1 || orientation > 8 {
return 1
}
return orientation
}
func orientPostImage(source image.Image, orientation int) image.Image {
if orientation <= 1 || orientation > 8 {
return source
}
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
targetWidth, targetHeight := width, height
if orientation >= 5 {
targetWidth, targetHeight = height, width
}
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
sourceX, sourceY := x, y
switch orientation {
case 2:
sourceX = width - 1 - x
case 3:
sourceX, sourceY = width-1-x, height-1-y
case 4:
sourceY = height - 1 - y
case 5:
sourceX, sourceY = y, x
case 6:
sourceX, sourceY = y, height-1-x
case 7:
sourceX, sourceY = width-1-y, height-1-x
case 8:
sourceX, sourceY = width-1-y, x
}
target.Set(x, y, source.At(bounds.Min.X+sourceX, bounds.Min.Y+sourceY))
}
}
return target
}
func fitPostImage(source image.Image, maxDimension int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDimension && height <= maxDimension {
return source
}
scale := float64(maxDimension) / float64(width)
if float64(height)*scale > float64(maxDimension) {
scale = float64(maxDimension) / float64(height)
}
targetWidth := max(1, int(float64(width)*scale))
targetHeight := max(1, int(float64(height)*scale))
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.CatmullRom.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
return target
}
func (s *Server) deletePostImageObjects(keys []string) {
if len(keys) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), postImageCleanupTimeout)
defer cancel()
for _, key := range keys {
if err := s.cfg.Blob.Delete(ctx, key); err != nil {
log.Printf("delete post image %s: %v", key, err)
}
}
}
func removedPostImageKeys(before, after []store.PostImage) []string {
retained := make(map[string]bool, len(after))
for _, image := range after {
retained[image.ObjectKey] = true
}
var removed []string
for _, image := range before {
if !retained[image.ObjectKey] {
removed = append(removed, image.ObjectKey)
}
}
return removed
}
+582
View File
@@ -0,0 +1,582 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"plumber/internal/blob"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestPreparePostImage(t *testing.T) {
t.Parallel()
wide := solidPNG(t, 2000, 1000)
prepared, err := preparePostImageHeader(t, "wide.png", wide)
if err != nil {
t.Fatal(err)
}
if prepared.width != 1600 || prepared.height != 800 ||
prepared.extension != ".png" || prepared.contentType != "image/png" {
t.Fatalf("prepared PNG = %+v", prepared)
}
jpegBody := solidJPEG(t, 40, 20)
prepared, err = preparePostImageHeader(t, "photo.jpg", jpegBody)
if err != nil {
t.Fatal(err)
}
if prepared.width != 40 || prepared.height != 20 ||
prepared.extension != ".jpg" || prepared.contentType != "image/jpeg" {
t.Fatalf("prepared JPEG = %+v", prepared)
}
if _, err := preparePostImageHeader(t, "notes.txt", []byte("not an image")); err == nil {
t.Fatal("text upload unexpectedly succeeded")
}
_, err = preparePostImageHeader(t, "too-large.jpg", make([]byte, postImageMaxFileBytes+1))
var requestErr *postImageRequestError
if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized file error = %v, want 413 request error", err)
}
if _, err := preparePostImageHeader(t, "too-wide.png", solidPNG(t, 6001, 1)); err == nil {
t.Fatal("oversized dimensions unexpectedly succeeded")
}
}
func TestPreparePostVideo(t *testing.T) {
t.Parallel()
prepared, err := preparePostVideoHeader(t, "clip.mp4", tinyMP4())
if err != nil {
t.Fatal(err)
}
if prepared.extension != ".mp4" || prepared.contentType != "video/mp4" || len(prepared.body) == 0 {
t.Fatalf("prepared MP4 = %+v", prepared)
}
prepared, err = preparePostVideoHeader(t, "clip.webm", tinyWebM())
if err != nil {
t.Fatal(err)
}
if prepared.extension != ".webm" || prepared.contentType != "video/webm" {
t.Fatalf("prepared WebM = %+v", prepared)
}
if _, err := preparePostVideoHeader(t, "notes.txt", []byte("not a video")); err == nil {
t.Fatal("text video upload unexpectedly succeeded")
}
_, err = preparePostVideoHeader(t, "too-large.mp4", make([]byte, postVideoMaxFileBytes+1))
var requestErr *postImageRequestError
if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized video error = %v, want 413 request error", err)
}
}
func TestOrientPostImage(t *testing.T) {
t.Parallel()
source := image.NewNRGBA(image.Rect(0, 0, 2, 1))
source.Set(0, 0, color.NRGBA{R: 255, A: 255})
source.Set(1, 0, color.NRGBA{B: 255, A: 255})
rotated := orientPostImage(source, 6)
if rotated.Bounds().Dx() != 1 || rotated.Bounds().Dy() != 2 {
t.Fatalf("rotated bounds = %v", rotated.Bounds())
}
top := color.NRGBAModel.Convert(rotated.At(0, 0)).(color.NRGBA)
bottom := color.NRGBAModel.Convert(rotated.At(0, 1)).(color.NRGBA)
if top.R != 255 || bottom.B != 255 {
t.Fatalf("rotation colors top=%v bottom=%v", top, bottom)
}
}
func TestPostImageMultipartLifecycle(t *testing.T) {
t.Parallel()
images := &recordingImageBlob{}
srv, mem := newTestServer(t, Config{Blob: images})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("images"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, homeowner.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Leaky valve"},
"body": {"Two views of the leak."},
"city": {"Oakland"},
"image_description": {"Front view", "Under the sink"},
}, []multipartTestFile{
{name: "front.png", body: solidPNG(t, 80, 40)},
{name: "under.jpg", body: solidJPEG(t, 40, 80)},
}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root image upload status = %d: %s", rec.Code, rec.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("roots = %+v, %v", roots, err)
}
root, err := mem.GetPost(context.Background(), roots[0].ID)
if err != nil {
t.Fatal(err)
}
if len(root.Images) != 2 ||
root.Images[0].Description != "Front view" ||
root.Images[1].Description != "Under the sink" {
t.Fatalf("root images = %+v", root.Images)
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Here is the model label."},
"image_description": {"Model label"},
}, []multipartTestFile{{name: "label.png", body: solidPNG(t, 60, 30)}}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("reply image upload status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
reply := thread.Replies[0]
if len(reply.Images) != 1 || reply.Images[0].Description != "Model label" {
t.Fatalf("reply images = %+v", reply.Images)
}
originalKey := reply.Images[0].ObjectKey
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Updated label photos."},
"existing_image_id": {reply.Images[0].ID},
"existing_image_description": {"Existing label"},
"image_description": {"Serial number"},
}, []multipartTestFile{{name: "serial.jpg", body: solidJPEG(t, 50, 25)}}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("image edit status = %d: %s", rec.Code, rec.Body.String())
}
edited, err := mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 2 ||
edited.Images[0].Description != "Existing label" ||
edited.Images[1].Description != "Serial number" {
t.Fatalf("edited images = %+v", edited.Images)
}
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Keep only the serial number."},
"existing_image_id": {edited.Images[1].ID},
"existing_image_description": {"Serial number"},
}, nil, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("image removal status = %d: %s", rec.Code, rec.Body.String())
}
edited, err = mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 1 || edited.Images[0].Description != "Serial number" {
t.Fatalf("images after removal = %+v", edited.Images)
}
if !images.wasDeleted(originalKey) {
t.Fatalf("removed object %q was not deleted: %+v", originalKey, images.deletedKeys())
}
uploadsBefore := images.uploadCount()
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Invalid retained image."},
"existing_image_id": {"not-owned"},
}, nil, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid retained image status = %d, want 400", rec.Code)
}
unchanged, err := mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if unchanged.Body != "Keep only the serial number." || len(unchanged.Images) != 1 {
t.Fatalf("invalid retained image changed post: %+v", unchanged)
}
files := make([]multipartTestFile, store.MaxPostImages+1)
for i := range files {
files[i] = multipartTestFile{name: "extra.png", body: solidPNG(t, 10, 10)}
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Too many images."},
}, files, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("five-image status = %d, want 400", rec.Code)
}
if images.uploadCount() != uploadsBefore {
t.Fatal("five-image request uploaded objects before rejecting count")
}
}
func 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)
}
rec = multipartPost(t, handler, "/posts/"+root.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Keep the clip."},
"existing_image_id": {root.Images[1].ID},
"existing_image_description": {"Kept clip"},
}, nil, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("retain video status = %d: %s", rec.Code, rec.Body.String())
}
edited, err := mem.GetPost(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 1 || edited.Images[0].Kind != store.MediaKindVideo ||
edited.Images[0].Description != "Kept clip" {
t.Fatalf("retained video = %+v", edited.Images)
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Two clips."},
}, []multipartTestFile{
{name: "a.mp4", body: tinyMP4()},
{name: "b.mp4", body: tinyMP4()},
}, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("two-video status = %d, want 400", rec.Code)
}
}
func TestPostImageUploadCompensation(t *testing.T) {
t.Parallel()
t.Run("blob failure deletes earlier upload", func(t *testing.T) {
images := &recordingImageBlob{failAt: 2}
srv, mem := newTestServer(t, Config{Blob: images})
handler := srv.Handler()
user := seedUser(t, mem, uniq("blob-fail"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Upload failure"},
"body": {"Should not persist."},
}, []multipartTestFile{
{name: "one.png", body: solidPNG(t, 10, 10)},
{name: "two.png", body: solidPNG(t, 10, 10)},
}, cookies)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("blob failure status = %d: %s", rec.Code, rec.Body.String())
}
if images.uploadCount() != 1 || len(images.deletedKeys()) != 1 {
t.Fatalf("blob compensation uploads=%d deletes=%v", images.uploadCount(), images.deletedKeys())
}
})
t.Run("store failure deletes uploaded object", func(t *testing.T) {
images := &recordingImageBlob{}
mem := store.NewMemory()
failing := &failingCreatePostStore{Store: mem}
srv := newTestServerStore(t, failing, Config{Blob: images})
handler := srv.Handler()
user := seedUser(t, mem, uniq("store-fail"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Store failure"},
"body": {"Should clean up."},
}, []multipartTestFile{{name: "one.png", body: solidPNG(t, 10, 10)}}, cookies)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("store failure status = %d: %s", rec.Code, rec.Body.String())
}
if images.uploadCount() != 1 || len(images.deletedKeys()) != 1 {
t.Fatalf("store compensation uploads=%d deletes=%v", images.uploadCount(), images.deletedKeys())
}
})
}
func TestPostImageRequestLimits(t *testing.T) {
t.Parallel()
for _, test := range []struct {
method string
path string
want int64
}{
{http.MethodPost, "/submit", postImageMaxRequestBytes},
{http.MethodPost, "/posts", postImageMaxRequestBytes},
{http.MethodPost, "/posts/id/edit", postImageMaxRequestBytes},
{http.MethodPost, "/login", defaultRequestBodyBytes},
{http.MethodGet, "/posts", defaultRequestBodyBytes},
} {
req := httptest.NewRequest(test.method, test.path, nil)
if got := requestBodyLimit(req); got != test.want {
t.Errorf("%s %s limit = %d, want %d", test.method, test.path, got, test.want)
}
}
}
type multipartTestFile struct {
name string
body []byte
}
func multipartPost(
t *testing.T,
handler http.Handler,
requestPath string,
fields map[string][]string,
files []multipartTestFile,
cookies []*http.Cookie,
) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for name, values := range fields {
for _, value := range values {
if err := writer.WriteField(name, value); err != nil {
t.Fatal(err)
}
}
}
for _, file := range files {
part, err := writer.CreateFormFile("images", file.name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(file.body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, requestPath, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func preparePostVideoHeader(t *testing.T, name string, body []byte) (preparedPostVideo, error) {
t.Helper()
header := multipartFileHeader(t, name, body)
return preparePostVideo(header)
}
func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) {
t.Helper()
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("images", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(body); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/posts", &requestBody)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(postImageMultipartMemory); err != nil {
t.Fatal(err)
}
defer req.MultipartForm.RemoveAll()
return preparePostImage(req.MultipartForm.File["images"][0])
}
func multipartFileHeader(t *testing.T, name string, body []byte) *multipart.FileHeader {
t.Helper()
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("images", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(body); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/posts", &requestBody)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(postImageMultipartMemory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = req.MultipartForm.RemoveAll() })
return req.MultipartForm.File["images"][0]
}
func tinyMP4() []byte {
body := make([]byte, 16)
body[3] = 16
copy(body[4:], "ftypisom")
copy(body[12:], "isom")
return body
}
func tinyWebM() []byte {
return []byte{0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00, 0x00, 0x00}
}
func solidPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.NRGBA{R: 30, G: 90, B: 140, A: 255})
}
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
func solidJPEG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.NRGBA{R: 140, G: 90, B: 30, A: 255})
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
type recordedImageUpload struct {
key string
contentType string
body []byte
}
type recordingImageBlob struct {
mu sync.Mutex
calls int
failAt int
uploads []recordedImageUpload
deletes []string
}
func (b *recordingImageBlob) Enabled() bool { return true }
func (b *recordingImageBlob) Upload(_ context.Context, object blob.FileUpload) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.calls++
if b.failAt > 0 && b.calls == b.failAt {
return "", errors.New("injected upload failure")
}
body, err := io.ReadAll(object.Body)
if err != nil {
return "", err
}
b.uploads = append(b.uploads, recordedImageUpload{
key: object.Key,
contentType: object.ContentType,
body: body,
})
return "https://cdn.example/" + object.Key, nil
}
func (b *recordingImageBlob) Delete(_ context.Context, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
b.deletes = append(b.deletes, key)
return nil
}
func (b *recordingImageBlob) uploadCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.uploads)
}
func (b *recordingImageBlob) deletedKeys() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string(nil), b.deletes...)
}
func (b *recordingImageBlob) wasDeleted(key string) bool {
for _, deleted := range b.deletedKeys() {
if deleted == key {
return true
}
}
return false
}
type failingCreatePostStore struct {
store.Store
}
func (f *failingCreatePostStore) CreatePost(context.Context, *store.Post) error {
return errors.New("injected store failure")
}
+28
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"plumber/internal/store"
)
@@ -17,6 +18,11 @@ import (
// handleCreatePost creates either a root question or a reply. Replies are
// limited to the root author and admins, and cannot be added to hidden threads.
func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -34,6 +40,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
}
post := &store.Post{
ID: uuid.NewString(),
AuthorID: user.ID,
Body: truncateRunes(body, 12000),
}
@@ -68,7 +75,14 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
root = threadRoot
}
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Images = images
if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
@@ -91,6 +105,11 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
// handleEditPost updates only a post's body after verifying that the current
// homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -119,8 +138,16 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "post body required", http.StatusBadRequest)
return
}
previousImages := append([]store.PostImage(nil), post.Images...)
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, previousImages)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Body = truncateRunes(body, 12000)
post.Images = images
if err := s.store.UpdatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
@@ -132,6 +159,7 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save post", http.StatusInternalServerError)
return
}
s.deletePostImageObjects(removedPostImageKeys(previousImages, images))
s.publishPostUpdated(post, root, nil)
http.Redirect(
w,
+90
View File
@@ -418,6 +418,12 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
Body: "Water under the cabinet.",
City: "Oakland",
PostDate: pacific.Today(),
Images: []store.PostImage{{
ID: "root-photo", ObjectKey: "post-images/root-photo.jpg",
PublicURL: "https://cdn.example/root-photo.jpg",
Description: "Water pooling below the shutoff valve",
Width: 1200, Height: 900,
}},
}
if err := mem.CreatePost(context.Background(), root); err != nil {
t.Fatal(err)
@@ -426,6 +432,11 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
ParentID: &root.ID,
AuthorID: homeowner.ID,
Body: "The model number is 123.",
Images: []store.PostImage{{
ID: "reply-photo", ObjectKey: "post-images/reply-photo.png",
PublicURL: "https://cdn.example/reply-photo.png",
Width: 900, Height: 1200,
}},
}
if err := mem.CreatePost(context.Background(), homeownerReply); err != nil {
t.Fatal(err)
@@ -434,6 +445,12 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
ParentID: &homeownerReply.ID,
AuthorID: admin.ID,
Body: "Replace the cartridge.",
Images: []store.PostImage{{
ID: "admin-photo", ObjectKey: "post-images/admin-photo.webp",
PublicURL: "https://cdn.example/admin-photo.webp",
Description: "Replacement cartridge orientation",
Width: 1000, Height: 1000,
}},
}
if err := mem.CreatePost(context.Background(), adminReply); err != nil {
t.Fatal(err)
@@ -465,6 +482,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`action="/posts"`,
`data-submit-once`,
`data-submit-button`,
`enctype="multipart/form-data"`,
`data-image-picker`,
`accept="image/jpeg,image/png,image/webp"`,
`aria-live="polite"`,
`name="existing_image_id" value="root-photo"`,
`name="existing_image_id" value="reply-photo"`,
`src="https://cdn.example/root-photo.jpg"`,
`alt="Water pooling below the shutoff valve"`,
`src="https://cdn.example/reply-photo.png"`,
`alt="Photo attached to this post"`,
`src="https://cdn.example/admin-photo.webp"`,
`loading="lazy" decoding="async"`,
`<figcaption>Replacement cartridge orientation</figcaption>`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`href="/questions/` + root.ID + `#post-` + root.ID + `"`,
@@ -480,6 +510,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
if got := strings.Count(body, ">Permalink</a>"); got != 3 {
t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body)
}
if got := strings.Count(body, `data-image-picker`); got != 5 {
t.Fatalf("question page rendered %d image pickers, want 5: %s", got, body)
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body)
}
@@ -495,6 +528,63 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
strings.Contains(rec.Body.String(), `action="/posts/`+root.ID+`/edit"`) {
t.Fatalf("admin edit controls are incorrect: %d %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil)
for _, cookie := range homeownerCookies {
req.AddCookie(cookie)
}
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("hunt page status = %d: %s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cdn.example") {
t.Fatalf("hunt page rendered post images: %s", rec.Body.String())
}
}
func TestImagePickerAssetsAreServed(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, Config{})
handler := srv.Handler()
for _, asset := range []struct {
path string
wants []string
}{
{
path: "/static/app.js",
wants: []string{
`const pickerSelector = "[data-image-picker]"`,
`new DataTransfer()`,
`addEventListener("drop"`,
`resetImagePicker`,
`URL.revokeObjectURL`,
},
},
{
path: "/static/app.css",
wants: []string{
`.image-dropzone`,
`.image-dropzone:focus-within`,
`.image-preview-list`,
`.post-image-grid`,
`@media (max-width: 520px)`,
},
},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, asset.path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d", asset.path, rec.Code)
}
for _, want := range asset.wants {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("%s missing %q", asset.path, want)
}
}
}
}
func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply {
+26 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"plumber/internal/blob"
"plumber/internal/events"
@@ -104,6 +105,11 @@ type threadPostCtx struct {
Depth int
}
type imagePickerCtx struct {
ID string
Images []store.PostImage
}
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
if cfg.Blob == nil {
cfg.Blob = blob.Disabled{}
@@ -118,6 +124,12 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
"postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx {
return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth}
},
"imagePicker": func(id string, images []store.PostImage) imagePickerCtx {
return imagePickerCtx{ID: id, Images: images}
},
"newImagePicker": func(id string) imagePickerCtx {
return imagePickerCtx{ID: id}
},
"add": func(a, b int) int { return a + b },
"rank": func(i int) int { return i + 1 },
"isAdmin": func(u *store.User) bool { return u.Admin() },
@@ -183,7 +195,7 @@ func (s *Server) Handler() http.Handler {
r.Use(middleware.Recoverer)
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 3<<20)
r.Body = http.MaxBytesReader(w, r.Body, requestBodyLimit(r))
next.ServeHTTP(w, r)
})
})
@@ -332,6 +344,11 @@ func (s *Server) handleSubmitForm(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -363,12 +380,20 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
city = truncateRunes(city, 80)
}
post := &store.Post{
ID: uuid.NewString(),
AuthorID: u.ID,
Title: title,
Body: body,
City: city,
}
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Images = images
if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
http.Error(w, "could not save question", http.StatusInternalServerError)
return
}
+5
View File
@@ -173,6 +173,11 @@ func TestRegisterLoginAsk(t *testing.T) {
`id="submit-progress"`,
`data-submit-once`,
`data-submit-button`,
`enctype="multipart/form-data"`,
`data-image-picker`,
`id="submit-images"`,
`accept="image/jpeg,image/png,image/webp"`,
`Add up to 4 JPEG, PNG, or WebP images.`,
} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("submit form missing %q: %s", want, rec.Body.String())
+13 -3
View File
@@ -48,13 +48,23 @@ CREATE TABLE IF NOT EXISTS post_images (
object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3),
width INTEGER NOT NULL CHECK (width > 0),
height INTEGER NOT NULL CHECK (height > 0),
kind TEXT NOT NULL DEFAULT 'image',
position SMALLINT NOT NULL,
width INTEGER NOT NULL,
height INTEGER 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)
);
CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx
ON post_images (post_id)
WHERE kind = 'video';
CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
+245
View File
@@ -749,6 +749,243 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.post-form-actions .btn { flex: 1 1 10rem; }
.image-picker {
min-width: 0;
margin: 10px 0;
padding: 0;
border: 0;
}
.image-picker legend {
margin-bottom: 6px;
padding: 0;
font-family: var(--mono);
font-weight: 500;
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.image-picker-hint {
margin: 0 0 8px;
color: var(--muted);
font-family: var(--mono);
font-size: 0.7rem;
line-height: 1.5;
text-wrap: pretty;
}
.image-dropzone {
position: relative;
min-height: 108px;
display: grid;
place-content: center;
gap: 8px;
padding: 18px 72px 18px 18px;
border: 1px dashed var(--zinc);
border-radius: 3px;
background: #181a1d;
transition: border-color 140ms ease, background-color 140ms ease;
}
.image-dropzone:hover,
.image-dropzone.is-dragging {
border-color: var(--signal);
background: #202124;
}
.image-dropzone:focus-within {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.image-input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.image-dropzone-label {
display: grid;
gap: 3px;
pointer-events: none;
text-align: center;
color: var(--ink);
}
.image-dropzone-label strong {
font-family: var(--sans);
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.image-dropzone-label span {
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.05em;
}
.image-picker-count {
position: absolute;
top: 10px;
right: 10px;
padding: 3px 6px;
border: 1px solid var(--line);
color: var(--muted);
background: var(--panel);
font-family: var(--mono);
font-size: 0.65rem;
letter-spacing: 0.06em;
pointer-events: none;
}
.image-picker-error {
margin: 8px 0 0;
color: #ffd0d0;
font-family: var(--mono);
font-size: 0.72rem;
}
.image-preview-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.image-preview {
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
border: 1px solid var(--line);
border-radius: 3px;
overflow: hidden;
background: #141516;
}
.image-preview[hidden] { display: none; }
.image-preview-media {
position: relative;
aspect-ratio: 4 / 3;
background: var(--bg);
overflow: hidden;
}
.image-preview-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview-tag {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 6px;
background: rgba(20, 21, 22, 0.9);
border: 1px solid var(--line);
color: var(--ink);
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.image-preview-fields {
min-width: 0;
display: grid;
align-content: start;
gap: 7px;
padding: 10px;
}
.image-preview-fields label {
display: grid;
gap: 6px;
}
.image-preview-name {
margin: 0;
overflow: hidden;
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-remove {
width: fit-content;
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-decoration: underline;
text-transform: uppercase;
cursor: pointer;
}
.image-remove:hover { color: #ffd0d0; }
.image-remove:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
}
.post-image-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
}
.post-image-grid-1 { grid-template-columns: minmax(0, 1fr); }
.post-image {
min-width: 0;
margin: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 3px;
background: #141516;
}
.post-image img {
width: 100%;
height: 100%;
max-height: 32rem;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.post-image-grid-1 .post-image img {
height: auto;
aspect-ratio: auto;
object-fit: contain;
}
.post-image figcaption {
padding: 8px 10px;
border-top: 1px solid var(--line);
color: var(--muted);
font-family: var(--mono);
font-size: 0.7rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.post-permalink {
display: inline-flex;
align-items: center;
@@ -797,8 +1034,16 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.panel-wrap { padding: 32px; }
}
@media (max-width: 520px) {
.image-preview-list,
.post-image-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.btn-primary:hover { filter: none; }
.image-dropzone { transition: none; }
.submit-progress-bar {
width: 100%;
animation: none;
+194
View File
@@ -1,5 +1,9 @@
(() => {
const formSelector = "form[data-submit-once]";
const pickerSelector = "[data-image-picker]";
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
const maxImageBytes = 5 * 1024 * 1024;
const pickerStates = new WeakMap();
function progressIndicator() {
return document.getElementById("submit-progress");
@@ -21,6 +25,193 @@
}
}
function existingImageCount(picker) {
return picker.querySelectorAll("[data-existing-image]:not([hidden])").length;
}
function updateImageCount(picker, state) {
const count = existingImageCount(picker) + state.entries.length;
const status = picker.querySelector("[data-image-count]");
if (status) {
status.textContent = `${count} of ${state.max}`;
}
}
function showImageError(picker, message) {
const error = picker.querySelector("[data-image-error]");
if (!error) {
return;
}
error.textContent = message;
error.hidden = !message;
}
function imageFileAllowed(file) {
if (allowedImageTypes.has(file.type)) {
return true;
}
if (file.type) {
return false;
}
return /\.(jpe?g|png|webp)$/i.test(file.name);
}
function sameImageFile(left, right) {
return left.name === right.name &&
left.size === right.size &&
left.lastModified === right.lastModified;
}
function syncImageInput(state) {
const transfer = new DataTransfer();
state.entries.forEach((entry) => transfer.items.add(entry.file));
state.input.files = transfer.files;
}
function removeNewImage(picker, state, entry) {
const index = state.entries.indexOf(entry);
if (index === -1) {
return;
}
state.entries.splice(index, 1);
URL.revokeObjectURL(entry.previewURL);
entry.card.remove();
syncImageInput(state);
showImageError(picker, "");
updateImageCount(picker, state);
}
function addImageFiles(picker, state, files) {
showImageError(picker, "");
const uniqueFiles = files.filter((file) =>
!state.entries.some((entry) => sameImageFile(entry.file, file))
);
const available = state.max - existingImageCount(picker) - state.entries.length;
if (uniqueFiles.length > available) {
showImageError(
picker,
available > 0
? `You can add ${available} more ${available === 1 ? "image" : "images"}.`
: "You already have 4 images selected."
);
syncImageInput(state);
return;
}
for (const file of uniqueFiles) {
if (!imageFileAllowed(file)) {
showImageError(picker, "Images must be JPEG, PNG, or WebP.");
syncImageInput(state);
return;
}
if (file.size > maxImageBytes) {
showImageError(picker, `${file.name} is larger than 5 MB.`);
syncImageInput(state);
return;
}
}
uniqueFiles.forEach((file) => {
const fragment = state.template.content.cloneNode(true);
const card = fragment.querySelector("[data-new-image]");
const preview = fragment.querySelector("[data-image-preview]");
const name = fragment.querySelector("[data-image-name]");
const previewURL = URL.createObjectURL(file);
preview.src = previewURL;
if (name) {
name.textContent = file.name;
}
const entry = { file, card, previewURL };
const removeButton = card.querySelector("[data-remove-image]");
removeButton.setAttribute("aria-label", `Remove selected image: ${file.name}`);
removeButton.addEventListener("click", () => {
removeNewImage(picker, state, entry);
});
state.list.appendChild(fragment);
state.entries.push(entry);
});
syncImageInput(state);
updateImageCount(picker, state);
}
function resetImagePicker(picker) {
const state = pickerStates.get(picker);
if (!state) {
return;
}
state.entries.forEach((entry) => {
URL.revokeObjectURL(entry.previewURL);
entry.card.remove();
});
state.entries = [];
state.input.value = "";
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
card.hidden = false;
card.querySelectorAll("input").forEach((input) => {
input.disabled = false;
});
});
showImageError(picker, "");
updateImageCount(picker, state);
}
function initializeImagePicker(picker) {
if (
pickerStates.has(picker) ||
typeof DataTransfer === "undefined" ||
typeof URL.createObjectURL !== "function"
) {
return;
}
const input = picker.querySelector("[data-image-input]");
const dropzone = picker.querySelector("[data-image-dropzone]");
const list = picker.querySelector("[data-image-list]");
const template = picker.querySelector("[data-image-template]");
if (!input || !dropzone || !list || !template) {
return;
}
const state = {
input,
list,
template,
entries: [],
max: Number.parseInt(picker.dataset.maxImages, 10) || 4,
};
pickerStates.set(picker, state);
updateImageCount(picker, state);
input.addEventListener("change", () => {
addImageFiles(picker, state, Array.from(input.files));
});
picker.querySelectorAll("[data-existing-image]").forEach((card) => {
card.querySelector("[data-remove-image]").addEventListener("click", () => {
card.hidden = true;
card.querySelectorAll("input").forEach((existingInput) => {
existingInput.disabled = true;
});
showImageError(picker, "");
updateImageCount(picker, state);
});
});
["dragenter", "dragover"].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
});
["dragleave", "drop"].forEach((eventName) => {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.remove("is-dragging");
});
});
dropzone.addEventListener("drop", (event) => {
addImageFiles(picker, state, Array.from(event.dataTransfer.files));
});
picker.closest("form")?.addEventListener("reset", () => {
window.setTimeout(() => resetImagePicker(picker), 0);
});
}
document.addEventListener("submit", (event) => {
const form = event.target.closest(formSelector);
if (!form) {
@@ -50,9 +241,12 @@
window.addEventListener("pageshow", () => {
document.querySelectorAll(formSelector).forEach(resetForm);
document.querySelectorAll(pickerSelector).forEach(resetImagePicker);
const progress = progressIndicator();
if (progress) {
progress.hidden = true;
}
});
document.querySelectorAll(pickerSelector).forEach(initializeImagePicker);
})();
+85
View File
@@ -0,0 +1,85 @@
{{define "imagePicker"}}
<fieldset class="image-picker" data-image-picker data-max-images="4">
<legend>Photos <span class="optional">(optional)</span></legend>
<p id="{{.ID}}-hint" class="image-picker-hint">
Add up to 4 JPEG, PNG, or WebP images. Each image can be up to 5 MB.
</p>
<div class="image-dropzone" data-image-dropzone>
<input id="{{.ID}}" class="image-input" type="file" name="images"
accept="image/jpeg,image/png,image/webp" multiple
aria-describedby="{{.ID}}-hint {{.ID}}-status {{.ID}}-error"
data-image-input>
<label class="image-dropzone-label" for="{{.ID}}">
<strong>Drop photos here</strong>
<span>or click to browse</span>
</label>
<span id="{{.ID}}-status" class="image-picker-count" role="status"
aria-live="polite" data-image-count>{{len .Images}} of 4</span>
</div>
<p id="{{.ID}}-error" class="image-picker-error" role="alert"
data-image-error hidden></p>
<div class="image-preview-list" data-image-list>
{{range .Images}}
<article class="image-preview" data-image-card data-existing-image>
<div class="image-preview-media">
<img src="{{.PublicURL}}" alt="" width="{{.Width}}" height="{{.Height}}">
<span class="image-preview-tag">Saved</span>
</div>
<div class="image-preview-fields">
<input type="hidden" name="existing_image_id" value="{{.ID}}">
<label for="{{$.ID}}-description-{{.ID}}">
Image description <span class="optional">(optional)</span>
</label>
<input id="{{$.ID}}-description-{{.ID}}" type="text"
name="existing_image_description" maxlength="500"
value="{{.Description}}" placeholder="What should people notice?">
<button class="image-remove" type="button" data-remove-image
aria-label="Remove image{{if .Description}}: {{.Description}}{{end}}">
Remove
</button>
</div>
</article>
{{end}}
</div>
<template data-image-template>
<article class="image-preview" data-image-card data-new-image>
<div class="image-preview-media">
<img alt="" data-image-preview>
<span class="image-preview-tag">New</span>
</div>
<div class="image-preview-fields">
<p class="image-preview-name" data-image-name></p>
<label>
Image description <span class="optional">(optional)</span>
<input type="text" name="image_description" maxlength="500"
placeholder="What should people notice?">
</label>
<button class="image-remove" type="button" data-remove-image
aria-label="Remove selected image">
Remove
</button>
</div>
</article>
</template>
<noscript>
<p class="image-picker-hint">Image previews and removal while editing require JavaScript.</p>
</noscript>
</fieldset>
{{end}}
{{define "postImages"}}
{{if .Images}}
<div class="post-image-grid post-image-grid-{{len .Images}}">
{{range .Images}}
<figure class="post-image">
<img src="{{.PublicURL}}" width="{{.Width}}" height="{{.Height}}"
alt="{{if .Description}}{{.Description}}{{else}}Photo attached to this post{{end}}"
loading="lazy" decoding="async">
{{if .Description}}<figcaption>{{.Description}}</figcaption>{{end}}
</figure>
{{end}}
</div>
{{end}}
{{end}}
+8 -3
View File
@@ -3,12 +3,13 @@
{{if canReply .User .Root}}
<details class="post-composer">
<summary>Reply</summary>
<form class="post-form" method="post" action="/posts"
<form class="post-form" method="post" action="/posts" enctype="multipart/form-data"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<input type="hidden" name="parent_id" value="{{.Post.ID}}">
<label for="reply-{{.Post.ID}}">Reply to {{.Post.AuthorName}}</label>
<textarea id="reply-{{.Post.ID}}" name="body" rows="5" required maxlength="12000"></textarea>
{{template "imagePicker" (newImagePicker (printf "reply-images-%s" .Post.ID))}}
<div class="post-form-actions">
<button type="submit" class="btn btn-primary" data-submit-button>Post reply</button>
<button type="reset" class="btn btn-ghost"
@@ -20,13 +21,16 @@
{{if canEditPost .User .Post}}
<details class="post-composer">
<summary>Edit</summary>
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit">
<form class="post-form" method="post" action="/posts/{{.Post.ID}}/edit"
enctype="multipart/form-data"
data-submit-once data-submitting-label="Saving…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="edit-{{.Post.ID}}">Edit post</label>
<textarea id="edit-{{.Post.ID}}" name="body" rows="5" required
maxlength="12000">{{.Post.Body}}</textarea>
{{template "imagePicker" (imagePicker (printf "edit-images-%s" .Post.ID) .Post.Images)}}
<div class="post-form-actions">
<button type="submit" class="btn btn-primary">Save changes</button>
<button type="submit" class="btn btn-primary" data-submit-button>Save changes</button>
<button type="reset" class="btn btn-ghost"
onclick="this.closest('details').removeAttribute('open')">Cancel</button>
</div>
@@ -59,6 +63,7 @@
</p>
</header>
<p class="post-body">{{.Post.Body}}</p>
{{template "postImages" .Post}}
{{template "postActions" .}}
{{if .Post.Replies}}
<div class="post-replies">
+1
View File
@@ -17,6 +17,7 @@
{{if isEdited .Question}}<span class="edited">Edited</span>{{end}}
</p>
<p class="post-body">{{.Question.Body}}</p>
{{template "postImages" .Question}}
{{template "postActions" (postCtx .User .CSRF .Question .Question 0)}}
</div>
</article>
+2 -1
View File
@@ -4,7 +4,7 @@
<h1>Ask a question</h1>
<p class="lede">It lands on todays hunt (Pacific time). People vote; the ranking resets at midnight PT.</p>
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
<form class="ask" method="post" action="/submit"
<form class="ask" method="post" action="/submit" enctype="multipart/form-data"
data-submit-once data-submitting-label="Posting…">
<input type="hidden" name="_csrf" value="{{.CSRF}}">
<label for="title">Title</label>
@@ -13,6 +13,7 @@
<textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea>
<label for="city">City <span class="optional">(optional)</span></label>
<input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland">
{{template "imagePicker" (newImagePicker "submit-images")}}
<button type="submit" class="btn btn-primary" data-submit-button>Submit to todays hunt</button>
</form>
</main>