Author SHA1 Message Date
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
codegirl007 c6f80e243d Merge pull request 'App' (#9) from app into master
CI / test (push) Successful in 6m17s
Reviewed-on: #9
2026-08-27 16:25:35 +00:00
codegirl007 8a2c7de8ca Add post permalinks (#8)
CI / test (pull_request) Successful in 6m17s
Reviewed-on: #8
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:21:38 +00:00
codegirl007 f33a0739c2 Initial Ask a Plumber First server 2026-08-22 12:26:32 -07:00
18 changed files with 1432 additions and 12 deletions
+46
View File
@@ -61,6 +61,52 @@ SET
updated_at = sqlc.arg(updated_at) updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id); 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
)
VALUES (
sqlc.arg(id),
sqlc.arg(post_id),
sqlc.arg(object_key),
sqlc.arg(public_url),
sqlc.arg(description),
sqlc.arg(position),
sqlc.arg(width),
sqlc.arg(height),
sqlc.arg(created_at)
);
-- name: DeletePostImages :exec
DELETE FROM post_images
WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = sqlc.arg(post_id)
ORDER BY position;
-- name: ListPostThreadImages :many
WITH RECURSIVE thread AS (
SELECT p.id
FROM posts p
WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
UNION ALL
SELECT child.id
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position;
-- name: UpdateRootPostState :execrows -- name: UpdateRootPostState :execrows
UPDATE posts UPDATE posts
SET SET
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0 golang.org/x/image v0.45.0
) )
+2
View File
@@ -45,6 +45,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/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 h1:t0Z/7k2sxnJGw8SjsCM9O8qkq3YRRHzTxWQNjhF2KhE=
github.com/resend/resend-go/v3 v3.16.0/go.mod h1:iI7VA0NoGjWvsNii5iNC5Dy0llsI3HncXPejhniYzwE= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+9
View File
@@ -209,6 +209,9 @@ func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if err := preparePost(post); err != nil { if err := preparePost(post); err != nil {
return err return err
} }
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok { if _, ok := m.users[post.AuthorID]; !ok {
@@ -287,6 +290,10 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if body == "" { if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost) return fmt.Errorf("%w: body is required", ErrInvalidPost)
} }
post.Body = body
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
existing, ok := m.posts[post.ID] existing, ok := m.posts[post.ID]
@@ -294,6 +301,7 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
return sql.ErrNoRows return sql.ErrNoRows
} }
existing.Body = body existing.Body = body
existing.Images = append([]PostImage(nil), post.Images...)
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users) *post = *clonePostWithAuthor(existing, m.users)
return nil return nil
@@ -451,6 +459,7 @@ func clonePost(post *Post) *Post {
parentID := *post.ParentID parentID := *post.ParentID
cp.ParentID = &parentID cp.ParentID = &parentID
} }
cp.Images = append([]PostImage(nil), post.Images...)
cp.Replies = nil cp.Replies = nil
return &cp return &cp
} }
+20
View File
@@ -120,6 +120,25 @@ func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
return nil return nil
} }
func migratePostImages(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS post_images (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
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),
created_at TEXT NOT NULL,
UNIQUE (post_id, position)
)`); err != nil {
return fmt.Errorf("create post images: %w", 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
@@ -311,6 +330,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"007_post_state", migratePostState}, {"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex}, {"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables}, {"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+39
View File
@@ -75,6 +75,12 @@ CREATE TABLE users (
if err := migratePostAuthorIndex(ctx, conn); err != nil { if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatalf("post author index migration is not idempotent: %v", err) t.Fatalf("post author index migration is not idempotent: %v", err)
} }
if err := migratePostImages(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostImages(ctx, conn); err != nil {
t.Fatalf("post images 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');
@@ -104,6 +110,39 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
if postCount != 2 || voteCount != 1 { if postCount != 2 || voteCount != 1 {
t.Fatalf("counts posts=%d votes=%d", postCount, voteCount) t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
} }
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"},
} {
if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err)
}
}
rootImages, err := imageQueries.ListPostImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(rootImages) != 2 ||
rootImages[0].ID != "root-image-1" ||
rootImages[1].ID != "root-image-2" {
t.Fatalf("root images = %+v", rootImages)
}
threadImages, err := imageQueries.ListPostThreadImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(threadImages) != 3 {
t.Fatalf("thread images = %+v", threadImages)
}
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,
Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("fifth image position unexpectedly succeeded")
}
var postVoteIndexCount int var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, ` if err := conn.QueryRowContext(ctx, `
SELECT count(*) SELECT count(*)
+160 -11
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"math"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -24,11 +25,26 @@ var (
type PostState string type PostState string
const ( const (
PostStateVisible PostState = "visible" PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden" PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked" PostStateLocked PostState = "locked"
MaxPostImages = 4
MaxImageDescriptionRunes = 500
) )
// PostImage is one ordered public image attached to a post.
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicURL string
Description string
Position int
Width int
Height int
CreatedAt string
}
// Post is either a root question (ParentID nil) or a reply to another post. // Post is either a root question (ParentID nil) or a reply to another post.
type Post struct { type Post struct {
ID string ID string
@@ -46,6 +62,7 @@ type Post struct {
Score int Score int
Answered bool Answered bool
UserVote int UserVote int
Images []PostImage
Replies []*Post Replies []*Post
db *sql.DB db *sql.DB
} }
@@ -63,7 +80,16 @@ func (p *Post) Create(ctx context.Context) error {
if err := preparePost(p); err != nil { if err := preparePost(p); err != nil {
return err return err
} }
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{ if err := preparePostImages(p); err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := sqlc.New(tx)
if err := q.CreatePost(ctx, sqlc.CreatePostParams{
ID: p.ID, ID: p.ID,
ParentID: nullableParentID(p.ParentID), ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID, AuthorID: p.AuthorID,
@@ -74,11 +100,16 @@ func (p *Post) Create(ctx context.Context) error {
PostState: string(p.PostState), PostState: string(p.PostState),
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
}) }); err != nil {
return mapPostCreateError(err) return mapPostCreateError(err)
}
if err := createPostImages(ctx, q, p.Images); err != nil {
return mapPostCreateError(err)
}
return tx.Commit()
} }
// Update changes only the post body and update timestamp. // Update changes the post body, update timestamp, and complete image set.
func (p *Post) Update(ctx context.Context) error { func (p *Post) Update(ctx context.Context) error {
if p == nil || p.db == nil { if p == nil || p.db == nil {
return fmt.Errorf("post: no database") return fmt.Errorf("post: no database")
@@ -88,7 +119,16 @@ func (p *Post) Update(ctx context.Context) error {
return fmt.Errorf("%w: body is required", ErrInvalidPost) return fmt.Errorf("%w: body is required", ErrInvalidPost)
} }
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{ if err := preparePostImages(p); err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := sqlc.New(tx)
n, err := q.UpdatePost(ctx, sqlc.UpdatePostParams{
ID: p.ID, ID: p.ID,
Body: p.Body, Body: p.Body,
UpdatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
@@ -99,7 +139,13 @@ func (p *Post) Update(ctx context.Context) error {
if n == 0 { if n == 0 {
return sql.ErrNoRows return sql.ErrNoRows
} }
return nil if err := q.DeletePostImages(ctx, p.ID); err != nil {
return err
}
if err := createPostImages(ctx, q, p.Images); err != nil {
return mapPostCreateError(err)
}
return tx.Commit()
} }
func preparePost(p *Post) error { func preparePost(p *Post) error {
@@ -153,6 +199,85 @@ func preparePost(p *Post) error {
return nil return nil
} }
func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano)
for i := range p.Images {
image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID)
image.PostID = strings.TrimSpace(image.PostID)
image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description)
if image.ID == "" {
image.ID = uuid.NewString()
}
if image.PostID == "" {
image.PostID = p.ID
}
if image.PostID != p.ID {
return fmt.Errorf("%w: image belongs to another post", ErrInvalidPost)
}
if image.ObjectKey == "" || image.PublicURL == "" {
return fmt.Errorf("%w: image storage metadata is required", ErrInvalidPost)
}
if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
}
if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost)
}
if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
}
ids[image.ID] = true
keys[image.ObjectKey] = true
image.Position = i
if image.CreatedAt == "" {
image.CreatedAt = now
}
}
return nil
}
func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage) error {
for _, image := range images {
if err := q.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: image.ID,
PostID: image.PostID,
ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL,
Description: image.Description,
Position: int16(image.Position),
Width: int32(image.Width),
Height: int32(image.Height),
CreatedAt: image.CreatedAt,
}); err != nil {
return err
}
}
return nil
}
func postImageFromSQL(image sqlc.PostImage) PostImage {
return PostImage{
ID: image.ID,
PostID: image.PostID,
ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl,
Description: image.Description,
Position: int(image.Position),
Width: int(image.Width),
Height: int(image.Height),
CreatedAt: image.CreatedAt,
}
}
func nullableParentID(parentID *string) sql.NullString { func nullableParentID(parentID *string) sql.NullString {
if parentID == nil { if parentID == nil {
return sql.NullString{} return sql.NullString{}
@@ -209,7 +334,8 @@ func postFromValues(
// GetPost returns one post without loading its replies. // GetPost returns one post without loading its replies.
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) { func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r, err := sqlc.New(db).GetPost(ctx, id) q := sqlc.New(db)
r, err := q.GetPost(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -228,12 +354,20 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r.CreatedAt, r.CreatedAt,
r.UpdatedAt, r.UpdatedAt,
) )
imageRows, err := q.ListPostImages(ctx, id)
if err != nil {
return nil, err
}
for _, image := range imageRows {
p.Images = append(p.Images, postImageFromSQL(image))
}
return &p, nil return &p, nil
} }
// GetPostThread returns a root post with all descendants nested under Replies. // GetPostThread returns a root post with all descendants nested under Replies.
func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) { func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) {
rows, err := sqlc.New(db).ListPostThread(ctx, rootID) q := sqlc.New(db)
rows, err := q.ListPostThread(ctx, rootID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -255,6 +389,21 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
r.UpdatedAt, r.UpdatedAt,
)) ))
} }
imageRows, err := q.ListPostThreadImages(ctx, rootID)
if err != nil {
return nil, err
}
postsByID := make(map[string]*Post, len(posts))
for i := range posts {
postsByID[posts[i].ID] = &posts[i]
}
for _, image := range imageRows {
post, ok := postsByID[image.PostID]
if !ok {
return nil, fmt.Errorf("image %s belongs to missing post %s", image.ID, image.PostID)
}
post.Images = append(post.Images, postImageFromSQL(image))
}
return buildPostTree(posts, rootID) return buildPostTree(posts, rootID)
} }
+87
View File
@@ -267,6 +267,93 @@ func TestMemoryPostValidation(t *testing.T) {
} }
} }
func TestMemoryPostImages(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "images", PasswordHash: "hash", Role: RoleUser}
if err := mem.CreateUser(ctx, homeowner); err != nil {
t.Fatal(err)
}
root := &Post{
ID: "image-root",
AuthorID: homeowner.ID,
Title: "What is leaking?",
Body: "Here are two photos.",
Images: []PostImage{
{ID: "image-a", ObjectKey: "posts/image-root/image-a.jpg", PublicURL: "https://cdn.example/image-a.jpg", Description: " Supply valve ", Position: 3, Width: 1200, Height: 900},
{ID: "image-b", ObjectKey: "posts/image-root/image-b.png", PublicURL: "https://cdn.example/image-b.png", Width: 900, Height: 1200},
},
}
if err := mem.CreatePost(ctx, root); err != nil {
t.Fatal(err)
}
if root.Images[0].Position != 0 ||
root.Images[1].Position != 1 ||
root.Images[0].PostID != root.ID ||
root.Images[0].Description != "Supply valve" {
t.Fatalf("created images were not normalized: %+v", root.Images)
}
loaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
loaded.Images[0].Description = "mutated outside store"
reloaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if reloaded.Images[0].Description != "Supply valve" {
t.Fatalf("stored image mutated through clone: %+v", reloaded.Images[0])
}
reloaded.Body = "Updated photos."
reloaded.Images = []PostImage{
reloaded.Images[1],
{ID: "image-c", ObjectKey: "posts/image-root/image-c.jpg", PublicURL: "https://cdn.example/image-c.jpg", Description: "Trap connection", Width: 1600, Height: 1000},
}
if err := mem.UpdatePost(ctx, reloaded); err != nil {
t.Fatal(err)
}
updated, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if len(updated.Images) != 2 ||
updated.Images[0].ID != "image-b" ||
updated.Images[0].Position != 0 ||
updated.Images[1].ID != "image-c" ||
updated.Images[1].Position != 1 {
t.Fatalf("updated images = %+v", updated.Images)
}
tooMany := &Post{
AuthorID: homeowner.ID,
Title: "Too many",
Body: "Five photos.",
Images: validPostImages(5),
}
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
}
}
func validPostImages(count int) []PostImage {
images := make([]PostImage, count)
for i := range images {
images[i] = PostImage{
ID: "image-" + string(rune('a'+i)),
ObjectKey: "posts/key-" + string(rune('a'+i)) + ".jpg",
PublicURL: "https://cdn.example/" + string(rune('a'+i)) + ".jpg",
Width: 100,
Height: 100,
}
}
return images
}
func ptr(value string) *string { func ptr(value string) *string {
return &value return &value
} }
+12
View File
@@ -22,6 +22,18 @@ type Post struct {
UpdatedAt string UpdatedAt string
} }
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicUrl string
Description string
Position int16
Width int32
Height int32
CreatedAt string
}
type PostVote struct { type PostVote struct {
UserID string UserID string
PostID string PostID string
+148
View File
@@ -57,6 +57,60 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
return err return err
} }
const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
`
type CreatePostImageParams struct {
ID string
PostID string
ObjectKey string
PublicUrl string
Description string
Position int16
Width int32
Height int32
CreatedAt string
}
func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams) error {
_, err := q.db.ExecContext(ctx, createPostImage,
arg.ID,
arg.PostID,
arg.ObjectKey,
arg.PublicUrl,
arg.Description,
arg.Position,
arg.Width,
arg.Height,
arg.CreatedAt,
)
return err
}
const deletePostImages = `-- name: DeletePostImages :exec
DELETE FROM post_images
WHERE post_id = $1
`
func (q *Queries) DeletePostImages(ctx context.Context, postID string) error {
_, err := q.db.ExecContext(ctx, deletePostImages, postID)
return err
}
const deletePostVote = `-- name: DeletePostVote :exec const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes DELETE FROM post_votes
WHERE user_id = $1 WHERE user_id = $1
@@ -145,6 +199,47 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
return i, err return i, err
} }
const listPostImages = `-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = $1
ORDER BY position
`
func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImage, error) {
rows, err := q.db.QueryContext(ctx, listPostImages, postID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PostImage{}
for rows.Next() {
var i PostImage
if err := rows.Scan(
&i.ID,
&i.PostID,
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Position,
&i.Width,
&i.Height,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPostThread = `-- name: ListPostThread :many const listPostThread = `-- name: ListPostThread :many
WITH RECURSIVE thread AS ( WITH RECURSIVE thread AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
@@ -218,6 +313,59 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
return items, nil return items, nil
} }
const listPostThreadImages = `-- name: ListPostThreadImages :many
WITH RECURSIVE thread AS (
SELECT p.id
FROM posts p
WHERE p.id = $1 AND p.parent_id IS NULL
UNION ALL
SELECT child.id
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at
FROM post_images images
JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position
`
func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]PostImage, error) {
rows, err := q.db.QueryContext(ctx, listPostThreadImages, rootID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PostImage{}
for rows.Next() {
var i PostImage
if err := rows.Scan(
&i.ID,
&i.PostID,
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Position,
&i.Width,
&i.Height,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootPosts = `-- name: ListRootPosts :many const listRootPosts = `-- name: ListRootPosts :many
WITH RECURSIVE roots AS ( WITH RECURSIVE roots AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
+395
View File
@@ -0,0 +1,395 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"plumber/internal/blob"
"plumber/internal/store"
)
const (
defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20
postImageMaxRequestBytes = 22 << 20
postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000
postImageMaxRenderedDim = 1600
postImageCleanupTimeout = 10 * time.Second
)
func requestBodyLimit(r *http.Request) int64 {
if r.Method != http.MethodPost {
return defaultRequestBodyBytes
}
switch {
case r.URL.Path == "/submit", r.URL.Path == "/posts":
return postImageMaxRequestBytes
case strings.HasPrefix(r.URL.Path, "/posts/") && strings.HasSuffix(r.URL.Path, "/edit"):
return postImageMaxRequestBytes
default:
return defaultRequestBodyBytes
}
}
type postImageRequestError struct {
status int
message string
cause error
}
func (e *postImageRequestError) Error() string {
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func parsePostMutationForm(w http.ResponseWriter, r *http.Request) (func(), bool) {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
return func() {}, false
}
if mediaType != "multipart/form-data" {
if err := r.ParseForm(); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
return func() {}, true
}
if err := r.ParseMultipartForm(postImageMultipartMemory); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
cleanup := func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}
return cleanup, true
}
func writePostImageRequestError(w http.ResponseWriter, err error) {
var requestErr *postImageRequestError
if errors.As(err, &requestErr) {
http.Error(w, requestErr.message, requestErr.status)
return
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "Image upload is too large.", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
}
func (s *Server) postImagesFromForm(
ctx context.Context,
r *http.Request,
postID string,
existing []store.PostImage,
) ([]store.PostImage, []string, error) {
if r.MultipartForm == nil {
return append([]store.PostImage(nil), existing...), nil, nil
}
retained, err := retainedPostImages(r.MultipartForm, existing)
if err != nil {
return nil, nil, err
}
files := r.MultipartForm.File["images"]
descriptions := r.MultipartForm.Value["image_description"]
if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
}
if len(retained)+len(files) > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Image uploads are not configured on this server.",
}
}
images := append([]store.PostImage(nil), retained...)
newKeys := make([]string, 0, len(files))
for i, header := range files {
description := ""
if i < len(descriptions) {
description = strings.TrimSpace(descriptions[i])
}
if len([]rune(description)) > store.MaxImageDescriptionRunes {
s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
prepared, err := preparePostImage(header)
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
ContentType: prepared.contentType,
Size: int64(len(prepared.body)),
})
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
newKeys = append(newKeys, objectKey)
images = append(images, store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Width: prepared.width,
Height: prepared.height,
})
}
return images, newKeys, nil
}
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
byID := make(map[string]store.PostImage, len(existing))
for _, image := range existing {
byID[image.ID] = image
}
ids := form.Value["existing_image_id"]
descriptions := form.Value["existing_image_description"]
if len(descriptions) > len(ids) {
return nil, invalidPostImage("Existing image descriptions do not match the images.", nil)
}
seen := make(map[string]bool, len(ids))
retained := make([]store.PostImage, 0, len(ids))
for i, id := range ids {
id = strings.TrimSpace(id)
image, ok := byID[id]
if !ok || seen[id] {
return nil, invalidPostImage("An existing image selection is invalid.", nil)
}
seen[id] = true
if i < len(descriptions) {
image.Description = strings.TrimSpace(descriptions[i])
}
if len([]rune(image.Description)) > store.MaxImageDescriptionRunes {
return nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
retained = append(retained, image)
}
return retained, nil
}
func invalidPostImage(message string, cause error) error {
return &postImageRequestError{status: http.StatusBadRequest, message: message, cause: cause}
}
type preparedPostImage struct {
body []byte
extension string
contentType string
width int
height int
}
func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
if header == nil {
return preparedPostImage{}, invalidPostImage("Select a valid image.", nil)
}
if header.Size > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postImageMaxFileBytes+1))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
if len(raw) == 0 {
return preparedPostImage{}, invalidPostImage("Images cannot be empty.", nil)
}
if int64(len(raw)) > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
sniffed := http.DetectContentType(raw)
switch sniffed {
case "image/jpeg", "image/png", "image/webp":
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > postImageMaxSourceDim || cfg.Height > postImageMaxSourceDim ||
int64(cfg.Width)*int64(cfg.Height) > postImageMaxSourcePixels {
return preparedPostImage{}, invalidPostImage("Image dimensions are too large.", nil)
}
decoded, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if format != "" {
decodedFormat = format
}
if sniffed == "image/jpeg" {
decoded = orientPostImage(decoded, jpegOrientation(raw))
}
decoded = fitPostImage(decoded, postImageMaxRenderedDim)
var out bytes.Buffer
result := preparedPostImage{}
switch decodedFormat {
case "jpeg":
if err := jpeg.Encode(&out, decoded, &jpeg.Options{Quality: 85}); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".jpg"
result.contentType = "image/jpeg"
case "png", "webp":
if err := png.Encode(&out, decoded); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".png"
result.contentType = "image/png"
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
result.body = out.Bytes()
result.width = decoded.Bounds().Dx()
result.height = decoded.Bounds().Dy()
return result, nil
}
func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil {
return 1
}
tag, err := metadata.Get(exif.Orientation)
if err != nil {
return 1
}
orientation, err := tag.Int(0)
if err != nil || orientation < 1 || orientation > 8 {
return 1
}
return orientation
}
func orientPostImage(source image.Image, orientation int) image.Image {
if orientation <= 1 || orientation > 8 {
return source
}
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
targetWidth, targetHeight := width, height
if orientation >= 5 {
targetWidth, targetHeight = height, width
}
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
sourceX, sourceY := x, y
switch orientation {
case 2:
sourceX = width - 1 - x
case 3:
sourceX, sourceY = width-1-x, height-1-y
case 4:
sourceY = height - 1 - y
case 5:
sourceX, sourceY = y, x
case 6:
sourceX, sourceY = y, height-1-x
case 7:
sourceX, sourceY = width-1-y, height-1-x
case 8:
sourceX, sourceY = width-1-y, x
}
target.Set(x, y, source.At(bounds.Min.X+sourceX, bounds.Min.Y+sourceY))
}
}
return target
}
func fitPostImage(source image.Image, maxDimension int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDimension && height <= maxDimension {
return source
}
scale := float64(maxDimension) / float64(width)
if float64(height)*scale > float64(maxDimension) {
scale = float64(maxDimension) / float64(height)
}
targetWidth := max(1, int(float64(width)*scale))
targetHeight := max(1, int(float64(height)*scale))
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.CatmullRom.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
return target
}
func (s *Server) deletePostImageObjects(keys []string) {
if len(keys) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), postImageCleanupTimeout)
defer cancel()
for _, key := range keys {
if err := s.cfg.Blob.Delete(ctx, key); err != nil {
log.Printf("delete post image %s: %v", key, err)
}
}
}
func removedPostImageKeys(before, after []store.PostImage) []string {
retained := make(map[string]bool, len(after))
for _, image := range after {
retained[image.ObjectKey] = true
}
var removed []string
for _, image := range before {
if !retained[image.ObjectKey] {
removed = append(removed, image.ObjectKey)
}
}
return removed
}
+443
View File
@@ -0,0 +1,443 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"plumber/internal/blob"
"plumber/internal/pacific"
"plumber/internal/store"
)
func TestPreparePostImage(t *testing.T) {
t.Parallel()
wide := solidPNG(t, 2000, 1000)
prepared, err := preparePostImageHeader(t, "wide.png", wide)
if err != nil {
t.Fatal(err)
}
if prepared.width != 1600 || prepared.height != 800 ||
prepared.extension != ".png" || prepared.contentType != "image/png" {
t.Fatalf("prepared PNG = %+v", prepared)
}
jpegBody := solidJPEG(t, 40, 20)
prepared, err = preparePostImageHeader(t, "photo.jpg", jpegBody)
if err != nil {
t.Fatal(err)
}
if prepared.width != 40 || prepared.height != 20 ||
prepared.extension != ".jpg" || prepared.contentType != "image/jpeg" {
t.Fatalf("prepared JPEG = %+v", prepared)
}
if _, err := preparePostImageHeader(t, "notes.txt", []byte("not an image")); err == nil {
t.Fatal("text upload unexpectedly succeeded")
}
_, err = preparePostImageHeader(t, "too-large.jpg", make([]byte, postImageMaxFileBytes+1))
var requestErr *postImageRequestError
if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized file error = %v, want 413 request error", err)
}
if _, err := preparePostImageHeader(t, "too-wide.png", solidPNG(t, 6001, 1)); err == nil {
t.Fatal("oversized dimensions unexpectedly succeeded")
}
}
func TestOrientPostImage(t *testing.T) {
t.Parallel()
source := image.NewNRGBA(image.Rect(0, 0, 2, 1))
source.Set(0, 0, color.NRGBA{R: 255, A: 255})
source.Set(1, 0, color.NRGBA{B: 255, A: 255})
rotated := orientPostImage(source, 6)
if rotated.Bounds().Dx() != 1 || rotated.Bounds().Dy() != 2 {
t.Fatalf("rotated bounds = %v", rotated.Bounds())
}
top := color.NRGBAModel.Convert(rotated.At(0, 0)).(color.NRGBA)
bottom := color.NRGBAModel.Convert(rotated.At(0, 1)).(color.NRGBA)
if top.R != 255 || bottom.B != 255 {
t.Fatalf("rotation colors top=%v bottom=%v", top, bottom)
}
}
func TestPostImageMultipartLifecycle(t *testing.T) {
t.Parallel()
images := &recordingImageBlob{}
srv, mem := newTestServer(t, Config{Blob: images})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("images"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, homeowner.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Leaky valve"},
"body": {"Two views of the leak."},
"city": {"Oakland"},
"image_description": {"Front view", "Under the sink"},
}, []multipartTestFile{
{name: "front.png", body: solidPNG(t, 80, 40)},
{name: "under.jpg", body: solidJPEG(t, 40, 80)},
}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root image upload status = %d: %s", rec.Code, rec.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("roots = %+v, %v", roots, err)
}
root, err := mem.GetPost(context.Background(), roots[0].ID)
if err != nil {
t.Fatal(err)
}
if len(root.Images) != 2 ||
root.Images[0].Description != "Front view" ||
root.Images[1].Description != "Under the sink" {
t.Fatalf("root images = %+v", root.Images)
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Here is the model label."},
"image_description": {"Model label"},
}, []multipartTestFile{{name: "label.png", body: solidPNG(t, 60, 30)}}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("reply image upload status = %d: %s", rec.Code, rec.Body.String())
}
thread, err := mem.GetPostThread(context.Background(), root.ID)
if err != nil || len(thread.Replies) != 1 {
t.Fatalf("thread = %+v, %v", thread, err)
}
reply := thread.Replies[0]
if len(reply.Images) != 1 || reply.Images[0].Description != "Model label" {
t.Fatalf("reply images = %+v", reply.Images)
}
originalKey := reply.Images[0].ObjectKey
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Updated label photos."},
"existing_image_id": {reply.Images[0].ID},
"existing_image_description": {"Existing label"},
"image_description": {"Serial number"},
}, []multipartTestFile{{name: "serial.jpg", body: solidJPEG(t, 50, 25)}}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("image edit status = %d: %s", rec.Code, rec.Body.String())
}
edited, err := mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 2 ||
edited.Images[0].Description != "Existing label" ||
edited.Images[1].Description != "Serial number" {
t.Fatalf("edited images = %+v", edited.Images)
}
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Keep only the serial number."},
"existing_image_id": {edited.Images[1].ID},
"existing_image_description": {"Serial number"},
}, nil, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("image removal status = %d: %s", rec.Code, rec.Body.String())
}
edited, err = mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 1 || edited.Images[0].Description != "Serial number" {
t.Fatalf("images after removal = %+v", edited.Images)
}
if !images.wasDeleted(originalKey) {
t.Fatalf("removed object %q was not deleted: %+v", originalKey, images.deletedKeys())
}
uploadsBefore := images.uploadCount()
rec = multipartPost(t, handler, "/posts/"+reply.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Invalid retained image."},
"existing_image_id": {"not-owned"},
}, nil, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid retained image status = %d, want 400", rec.Code)
}
unchanged, err := mem.GetPost(context.Background(), reply.ID)
if err != nil {
t.Fatal(err)
}
if unchanged.Body != "Keep only the serial number." || len(unchanged.Images) != 1 {
t.Fatalf("invalid retained image changed post: %+v", unchanged)
}
files := make([]multipartTestFile, store.MaxPostImages+1)
for i := range files {
files[i] = multipartTestFile{name: "extra.png", body: solidPNG(t, 10, 10)}
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Too many images."},
}, files, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("five-image status = %d, want 400", rec.Code)
}
if images.uploadCount() != uploadsBefore {
t.Fatal("five-image request uploaded objects before rejecting count")
}
}
func TestPostImageUploadCompensation(t *testing.T) {
t.Parallel()
t.Run("blob failure deletes earlier upload", func(t *testing.T) {
images := &recordingImageBlob{failAt: 2}
srv, mem := newTestServer(t, Config{Blob: images})
handler := srv.Handler()
user := seedUser(t, mem, uniq("blob-fail"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Upload failure"},
"body": {"Should not persist."},
}, []multipartTestFile{
{name: "one.png", body: solidPNG(t, 10, 10)},
{name: "two.png", body: solidPNG(t, 10, 10)},
}, cookies)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("blob failure status = %d: %s", rec.Code, rec.Body.String())
}
if images.uploadCount() != 1 || len(images.deletedKeys()) != 1 {
t.Fatalf("blob compensation uploads=%d deletes=%v", images.uploadCount(), images.deletedKeys())
}
})
t.Run("store failure deletes uploaded object", func(t *testing.T) {
images := &recordingImageBlob{}
mem := store.NewMemory()
failing := &failingCreatePostStore{Store: mem}
srv := newTestServerStore(t, failing, Config{Blob: images})
handler := srv.Handler()
user := seedUser(t, mem, uniq("store-fail"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, user.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Store failure"},
"body": {"Should clean up."},
}, []multipartTestFile{{name: "one.png", body: solidPNG(t, 10, 10)}}, cookies)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("store failure status = %d: %s", rec.Code, rec.Body.String())
}
if images.uploadCount() != 1 || len(images.deletedKeys()) != 1 {
t.Fatalf("store compensation uploads=%d deletes=%v", images.uploadCount(), images.deletedKeys())
}
})
}
func TestPostImageRequestLimits(t *testing.T) {
t.Parallel()
for _, test := range []struct {
method string
path string
want int64
}{
{http.MethodPost, "/submit", postImageMaxRequestBytes},
{http.MethodPost, "/posts", postImageMaxRequestBytes},
{http.MethodPost, "/posts/id/edit", postImageMaxRequestBytes},
{http.MethodPost, "/login", defaultRequestBodyBytes},
{http.MethodGet, "/posts", defaultRequestBodyBytes},
} {
req := httptest.NewRequest(test.method, test.path, nil)
if got := requestBodyLimit(req); got != test.want {
t.Errorf("%s %s limit = %d, want %d", test.method, test.path, got, test.want)
}
}
}
type multipartTestFile struct {
name string
body []byte
}
func multipartPost(
t *testing.T,
handler http.Handler,
requestPath string,
fields map[string][]string,
files []multipartTestFile,
cookies []*http.Cookie,
) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for name, values := range fields {
for _, value := range values {
if err := writer.WriteField(name, value); err != nil {
t.Fatal(err)
}
}
}
for _, file := range files {
part, err := writer.CreateFormFile("images", file.name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(file.body); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, requestPath, &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) {
t.Helper()
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("images", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(body); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/posts", &requestBody)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(postImageMultipartMemory); err != nil {
t.Fatal(err)
}
defer req.MultipartForm.RemoveAll()
return preparePostImage(req.MultipartForm.File["images"][0])
}
func solidPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.NRGBA{R: 30, G: 90, B: 140, A: 255})
}
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
func solidJPEG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
img.Set(x, y, color.NRGBA{R: 140, G: 90, B: 30, A: 255})
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
type recordedImageUpload struct {
key string
contentType string
body []byte
}
type recordingImageBlob struct {
mu sync.Mutex
calls int
failAt int
uploads []recordedImageUpload
deletes []string
}
func (b *recordingImageBlob) Enabled() bool { return true }
func (b *recordingImageBlob) Upload(_ context.Context, object blob.FileUpload) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.calls++
if b.failAt > 0 && b.calls == b.failAt {
return "", errors.New("injected upload failure")
}
body, err := io.ReadAll(object.Body)
if err != nil {
return "", err
}
b.uploads = append(b.uploads, recordedImageUpload{
key: object.Key,
contentType: object.ContentType,
body: body,
})
return "https://cdn.example/" + object.Key, nil
}
func (b *recordingImageBlob) Delete(_ context.Context, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
b.deletes = append(b.deletes, key)
return nil
}
func (b *recordingImageBlob) uploadCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.uploads)
}
func (b *recordingImageBlob) deletedKeys() []string {
b.mu.Lock()
defer b.mu.Unlock()
return append([]string(nil), b.deletes...)
}
func (b *recordingImageBlob) wasDeleted(key string) bool {
for _, deleted := range b.deletedKeys() {
if deleted == key {
return true
}
}
return false
}
type failingCreatePostStore struct {
store.Store
}
func (f *failingCreatePostStore) CreatePost(context.Context, *store.Post) error {
return errors.New("injected store failure")
}
+28
View File
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
"plumber/internal/mail" "plumber/internal/mail"
"plumber/internal/store" "plumber/internal/store"
@@ -20,6 +21,11 @@ import (
// handleCreatePost creates either a root question or a reply. Replies are // 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. // limited to the root author and admins, and cannot be added to hidden threads.
func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) { 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) { if !s.requireCSRF(w, r) {
return return
} }
@@ -37,6 +43,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
} }
post := &store.Post{ post := &store.Post{
ID: uuid.NewString(),
AuthorID: user.ID, AuthorID: user.ID,
Body: truncateRunes(body, 12000), Body: truncateRunes(body, 12000),
} }
@@ -71,7 +78,14 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
root = threadRoot 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 { if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) { if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest) http.Error(w, "invalid post", http.StatusBadRequest)
return return
@@ -149,6 +163,11 @@ func (s *Server) notifyPostReply(
// handleEditPost updates only a post's body after verifying that the current // 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. // homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) { 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) { if !s.requireCSRF(w, r) {
return return
} }
@@ -177,8 +196,16 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "post body required", http.StatusBadRequest) http.Error(w, "post body required", http.StatusBadRequest)
return 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.Body = truncateRunes(body, 12000)
post.Images = images
if err := s.store.UpdatePost(r.Context(), post); err != nil { if err := s.store.UpdatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) { if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest) http.Error(w, "invalid post", http.StatusBadRequest)
return return
@@ -190,6 +217,7 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not save post", http.StatusInternalServerError) http.Error(w, "could not save post", http.StatusInternalServerError)
return return
} }
s.deletePostImageObjects(removedPostImageKeys(previousImages, images))
http.Redirect( http.Redirect(
w, w,
r, r,
+6
View File
@@ -462,6 +462,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`data-submit-button`, `data-submit-button`,
`action="/posts/` + root.ID + `/edit"`, `action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`,
`href="/questions/` + root.ID + `#post-` + root.ID + `"`,
`href="/questions/` + root.ID + `#post-` + homeownerReply.ID + `"`,
`href="/questions/` + root.ID + `#post-` + adminReply.ID + `"`,
`>The model number is 123A.</textarea>`, `>The model number is 123A.</textarea>`,
`removeAttribute('open')`, `removeAttribute('open')`,
} { } {
@@ -469,6 +472,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
t.Fatalf("question page missing %q: %s", want, body) t.Fatalf("question page missing %q: %s", want, body)
} }
} }
if got := strings.Count(body, ">Permalink</a>"); got != 3 {
t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body)
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) { if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body) t.Fatalf("homeowner can edit admin reply: %s", body)
} }
+15 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"plumber/internal/blob" "plumber/internal/blob"
"plumber/internal/geo" "plumber/internal/geo"
@@ -182,7 +183,7 @@ func (s *Server) Handler() http.Handler {
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(func(next http.Handler) http.Handler { r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) next.ServeHTTP(w, r)
}) })
}) })
@@ -331,6 +332,11 @@ func (s *Server) handleSubmitForm(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSubmit(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) { if !s.requireCSRF(w, r) {
return return
} }
@@ -362,12 +368,20 @@ func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
city = truncateRunes(city, 80) city = truncateRunes(city, 80)
} }
post := &store.Post{ post := &store.Post{
ID: uuid.NewString(),
AuthorID: u.ID, AuthorID: u.ID,
Title: title, Title: title,
Body: body, Body: body,
City: city, 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 { if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
http.Error(w, "could not save question", http.StatusInternalServerError) http.Error(w, "could not save question", http.StatusInternalServerError)
return return
} }
+13
View File
@@ -42,6 +42,19 @@ CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state) ON posts(post_date, post_state)
WHERE parent_id IS NULL; WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS post_images (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
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),
created_at TEXT NOT NULL,
UNIQUE (post_id, position)
);
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,
+5
View File
@@ -749,6 +749,11 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.post-form-actions .btn { flex: 1 1 10rem; } .post-form-actions .btn { flex: 1 1 10rem; }
.post-permalink {
display: inline-flex;
align-items: center;
}
.post-hide { .post-hide {
margin: 0; margin: 0;
} }
+3
View File
@@ -33,6 +33,9 @@
</form> </form>
</details> </details>
{{end}} {{end}}
<a class="linkish post-permalink"
href="/questions/{{.Root.ID}}#post-{{.Post.ID}}"
aria-label="Permanent link to post by {{.Post.AuthorName}}">Permalink</a>
{{if and (not .Post.ParentID) (isAdmin .User)}} {{if and (not .Post.ParentID) (isAdmin .User)}}
<form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide"> <form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide">
<input type="hidden" name="_csrf" value="{{.CSRF}}"> <input type="hidden" name="_csrf" value="{{.CSRF}}">