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 f420f888af Remove legacy question storage (#7)
Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

Reviewed-on: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:17:57 +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)
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
UPDATE posts
SET
+1
View File
@@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
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/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/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=
+9
View File
@@ -209,6 +209,9 @@ func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if err := preparePost(post); err != nil {
return err
}
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok {
@@ -287,6 +290,10 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
post.Body = body
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.posts[post.ID]
@@ -294,6 +301,7 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
return sql.ErrNoRows
}
existing.Body = body
existing.Images = append([]PostImage(nil), post.Images...)
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
@@ -451,6 +459,7 @@ func clonePost(post *Post) *Post {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Images = append([]PostImage(nil), post.Images...)
cp.Replies = nil
return &cp
}
+20
View File
@@ -120,6 +120,25 @@ func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
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 {
steps := []struct {
name string
@@ -311,6 +330,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
}
for _, m := range migrations {
if applied[m.version] {
+39
View File
@@ -75,6 +75,12 @@ CREATE TABLE users (
if err := migratePostAuthorIndex(ctx, conn); err != nil {
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, `
INSERT INTO users (id, name, role)
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 {
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
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
+156 -7
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
@@ -27,8 +28,23 @@ const (
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
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.
type Post struct {
ID string
@@ -46,6 +62,7 @@ type Post struct {
Score int
Answered bool
UserVote int
Images []PostImage
Replies []*Post
db *sql.DB
}
@@ -63,7 +80,16 @@ func (p *Post) Create(ctx context.Context) error {
if err := preparePost(p); err != nil {
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,
ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID,
@@ -74,11 +100,16 @@ func (p *Post) Create(ctx context.Context) error {
PostState: string(p.PostState),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
}); err != nil {
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 {
if p == nil || p.db == nil {
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)
}
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,
Body: p.Body,
UpdatedAt: p.UpdatedAt,
@@ -99,7 +139,13 @@ func (p *Post) Update(ctx context.Context) error {
if n == 0 {
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 {
@@ -153,6 +199,85 @@ func preparePost(p *Post) error {
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 {
if parentID == nil {
return sql.NullString{}
@@ -209,7 +334,8 @@ func postFromValues(
// GetPost returns one post without loading its replies.
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 {
return nil, err
}
@@ -228,12 +354,20 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r.CreatedAt,
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
}
// GetPostThread returns a root post with all descendants nested under Replies.
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 {
return nil, err
}
@@ -255,6 +389,21 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
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)
}
+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 {
return &value
}
+12
View File
@@ -22,6 +22,18 @@ type Post struct {
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 {
UserID string
PostID string
+148
View File
@@ -57,6 +57,60 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
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
DELETE FROM post_votes
WHERE user_id = $1
@@ -145,6 +199,47 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
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
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
@@ -218,6 +313,59 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
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
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
+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"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"plumber/internal/mail"
"plumber/internal/store"
@@ -20,6 +21,11 @@ import (
// handleCreatePost creates either a root question or a reply. Replies are
// limited to the root author and admins, and cannot be added to hidden threads.
func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -37,6 +43,7 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
}
post := &store.Post{
ID: uuid.NewString(),
AuthorID: user.ID,
Body: truncateRunes(body, 12000),
}
@@ -71,7 +78,14 @@ func (s *Server) handleCreatePost(w http.ResponseWriter, r *http.Request) {
root = threadRoot
}
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, nil)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Images = images
if err := s.store.CreatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
@@ -149,6 +163,11 @@ func (s *Server) notifyPostReply(
// handleEditPost updates only a post's body after verifying that the current
// homeowner owns it or that an admin is editing an admin-authored post.
func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
cleanup, ok := parsePostMutationForm(w, r)
if !ok {
return
}
defer cleanup()
if !s.requireCSRF(w, r) {
return
}
@@ -177,8 +196,16 @@ func (s *Server) handleEditPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "post body required", http.StatusBadRequest)
return
}
previousImages := append([]store.PostImage(nil), post.Images...)
images, newKeys, err := s.postImagesFromForm(r.Context(), r, post.ID, previousImages)
if err != nil {
writePostImageRequestError(w, err)
return
}
post.Body = truncateRunes(body, 12000)
post.Images = images
if err := s.store.UpdatePost(r.Context(), post); err != nil {
s.deletePostImageObjects(newKeys)
if errors.Is(err, store.ErrInvalidPost) {
http.Error(w, "invalid post", http.StatusBadRequest)
return
@@ -190,6 +217,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))
http.Redirect(
w,
r,
+6
View File
@@ -462,6 +462,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`data-submit-button`,
`action="/posts/` + root.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>`,
`removeAttribute('open')`,
} {
@@ -469,6 +472,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
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"`) {
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/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"plumber/internal/blob"
"plumber/internal/geo"
@@ -182,7 +183,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)
})
})
@@ -331,6 +332,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
}
@@ -362,12 +368,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
}
+13
View File
@@ -42,6 +42,19 @@ CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
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 (
user_id TEXT NOT NULL REFERENCES users(id),
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-permalink {
display: inline-flex;
align-items: center;
}
.post-hide {
margin: 0;
}
+3
View File
@@ -33,6 +33,9 @@
</form>
</details>
{{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)}}
<form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide">
<input type="hidden" name="_csrf" value="{{.CSRF}}">