Compare commits

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

Reviewed-on: #21
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-31 04:37:08 +00:00
12 changed files with 478 additions and 39 deletions
+4 -3
View File
@@ -63,7 +63,7 @@ WHERE id = sqlc.arg(id);
-- name: CreatePostImage :exec -- name: CreatePostImage :exec
INSERT INTO post_images ( INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at id, post_id, object_key, public_url, description, kind, position, width, height, created_at
) )
VALUES ( VALUES (
sqlc.arg(id), sqlc.arg(id),
@@ -71,6 +71,7 @@ VALUES (
sqlc.arg(object_key), sqlc.arg(object_key),
sqlc.arg(public_url), sqlc.arg(public_url),
sqlc.arg(description), sqlc.arg(description),
sqlc.arg(kind),
sqlc.arg(position), sqlc.arg(position),
sqlc.arg(width), sqlc.arg(width),
sqlc.arg(height), sqlc.arg(height),
@@ -83,7 +84,7 @@ WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many -- name: ListPostImages :many
SELECT SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at id, post_id, object_key, public_url, description, kind, position, width, height, created_at
FROM post_images FROM post_images
WHERE post_id = sqlc.arg(post_id) WHERE post_id = sqlc.arg(post_id)
ORDER BY position; ORDER BY position;
@@ -102,7 +103,7 @@ WITH RECURSIVE thread AS (
) )
SELECT SELECT
images.id, images.post_id, images.object_key, images.public_url, images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at images.description, images.kind, images.position, images.width, images.height, images.created_at
FROM post_images images FROM post_images images
JOIN thread ON thread.id = images.post_id JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position; ORDER BY images.post_id, images.position;
+27
View File
@@ -158,6 +158,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
return nil return nil
} }
func migratePostImageVideo(ctx context.Context, exec execContext) error {
steps := []struct {
name string
sql string
}{
{"add kind", `ALTER TABLE post_images ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'image'`},
{"drop kind check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_kind_check`},
{"add kind check", `ALTER TABLE post_images ADD CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video'))`},
{"drop width check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_width_check`},
{"drop height check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_height_check`},
{"add width check", `ALTER TABLE post_images ADD CONSTRAINT post_images_width_check CHECK (width >= 0)`},
{"add height check", `ALTER TABLE post_images ADD CONSTRAINT post_images_height_check CHECK (height >= 0)`},
{"drop image dims check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_image_dims_check`},
{"add image dims check", `ALTER TABLE post_images ADD CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0))`},
{"drop position check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_position_check`},
{"add position check", `ALTER TABLE post_images ADD CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4)`},
{"one video index", `CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx ON post_images (post_id) WHERE kind = 'video'`},
}
for _, step := range steps {
if _, err := exec.ExecContext(ctx, step.sql); err != nil {
return fmt.Errorf("%s: %w", step.name, err)
}
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error { func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
@@ -351,6 +377,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables}, {"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages}, {"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks}, {"011_discord_post_links", migrateDiscordPostLinks},
{"012_post_image_video", migratePostImageVideo},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+25 -5
View File
@@ -87,6 +87,12 @@ CREATE TABLE users (
if err := migrateDiscordPostLinks(ctx, conn); err != nil { if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err) t.Fatalf("discord post links migration is not idempotent: %v", err)
} }
if err := migratePostImageVideo(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostImageVideo(ctx, conn); err != nil {
t.Fatalf("post image video migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, ` if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role) INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin'); VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
@@ -118,9 +124,9 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
} }
imageQueries := sqlc.New(conn) imageQueries := sqlc.New(conn)
for _, image := range []sqlc.CreatePostImageParams{ for _, image := range []sqlc.CreatePostImageParams{
{ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"}, {ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Kind: "image", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"}, {ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Kind: "image", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"},
{ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"}, {ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Kind: "image", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"},
} { } {
if err := imageQueries.CreatePostImage(ctx, image); err != nil { if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -144,10 +150,24 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
} }
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{ if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg", ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg",
PublicUrl: "https://cdn.example/root-5.jpg", Position: 4, PublicUrl: "https://cdn.example/root-5.jpg", Kind: "image", Position: 5,
Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z", Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil { }); err == nil {
t.Fatal("fifth image position unexpectedly succeeded") t.Fatal("position 5 unexpectedly succeeded")
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "root-video-1", PostID: "root-1", ObjectKey: "posts/root-1/clip.mp4",
PublicUrl: "https://cdn.example/clip.mp4", Kind: "video", Position: 2,
Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z",
}); err != nil {
t.Fatal(err)
}
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "root-video-2", PostID: "root-1", ObjectKey: "posts/root-1/clip-2.mp4",
PublicUrl: "https://cdn.example/clip-2.mp4", Kind: "video", Position: 3,
Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("second video unexpectedly succeeded")
} }
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{ if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1", PostID: "root-1",
+35 -6
View File
@@ -29,16 +29,20 @@ const (
PostStateHidden PostState = "hidden" PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked" PostStateLocked PostState = "locked"
MaxPostImages = 4 MaxPostImages = 4
MaxPostVideos = 1
MaxImageDescriptionRunes = 500 MaxImageDescriptionRunes = 500
MediaKindImage = "image"
MediaKindVideo = "video"
) )
// PostImage is one ordered public image attached to a post. // PostImage is one ordered public image or video attached to a post.
type PostImage struct { type PostImage struct {
ID string ID string
PostID string PostID string
ObjectKey string ObjectKey string
PublicURL string PublicURL string
Description string Description string
Kind string
Position int Position int
Width int Width int
Height int Height int
@@ -200,12 +204,13 @@ func preparePost(p *Post) error {
} }
func preparePostImages(p *Post) error { func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages { if len(p.Images) > MaxPostImages+MaxPostVideos {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages) return fmt.Errorf("%w: at most %d images and %d video are allowed", ErrInvalidPost, MaxPostImages, MaxPostVideos)
} }
ids := make(map[string]bool, len(p.Images)) ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images)) keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)
images, videos := 0, 0
for i := range p.Images { for i := range p.Images {
image := &p.Images[i] image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID) image.ID = strings.TrimSpace(image.ID)
@@ -213,6 +218,13 @@ func preparePostImages(p *Post) error {
image.ObjectKey = strings.TrimSpace(image.ObjectKey) image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL) image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description) image.Description = strings.TrimSpace(image.Description)
image.Kind = strings.TrimSpace(image.Kind)
if image.Kind == "" {
image.Kind = MediaKindImage
}
if image.Kind != MediaKindImage && image.Kind != MediaKindVideo {
return fmt.Errorf("%w: invalid media kind", ErrInvalidPost)
}
if image.ID == "" { if image.ID == "" {
image.ID = uuid.NewString() image.ID = uuid.NewString()
} }
@@ -228,9 +240,18 @@ func preparePostImages(p *Post) error {
if len([]rune(image.Description)) > MaxImageDescriptionRunes { if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost) return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
} }
if image.Width <= 0 || image.Height <= 0 || if image.Kind == MediaKindImage {
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { images++
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost) if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost)
}
} else {
videos++
if image.Width < 0 || image.Height < 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid video dimensions", ErrInvalidPost)
}
} }
if ids[image.ID] || keys[image.ObjectKey] { if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost) return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
@@ -242,6 +263,12 @@ func preparePostImages(p *Post) error {
image.CreatedAt = now image.CreatedAt = now
} }
} }
if images > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
if videos > MaxPostVideos {
return fmt.Errorf("%w: at most %d video is allowed", ErrInvalidPost, MaxPostVideos)
}
return nil return nil
} }
@@ -253,6 +280,7 @@ func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage)
ObjectKey: image.ObjectKey, ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL, PublicUrl: image.PublicURL,
Description: image.Description, Description: image.Description,
Kind: image.Kind,
Position: int16(image.Position), Position: int16(image.Position),
Width: int32(image.Width), Width: int32(image.Width),
Height: int32(image.Height), Height: int32(image.Height),
@@ -271,6 +299,7 @@ func postImageFromSQL(image sqlc.PostImage) PostImage {
ObjectKey: image.ObjectKey, ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl, PublicURL: image.PublicUrl,
Description: image.Description, Description: image.Description,
Kind: image.Kind,
Position: int(image.Position), Position: int(image.Position),
Width: int(image.Width), Width: int(image.Width),
Height: int(image.Height), Height: int(image.Height),
+30
View File
@@ -338,6 +338,36 @@ func TestMemoryPostImages(t *testing.T) {
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) { if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err) t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
} }
withVideo := &Post{
AuthorID: homeowner.ID,
Title: "With video",
Body: "Four photos and a clip.",
Images: append(validPostImages(4), PostImage{
ID: "clip-1",
ObjectKey: "posts/clip-1.mp4",
PublicURL: "https://cdn.example/clip-1.mp4",
Kind: MediaKindVideo,
}),
}
if err := mem.CreatePost(ctx, withVideo); err != nil {
t.Fatalf("four images and one video: %v", err)
}
if withVideo.Images[4].Kind != MediaKindVideo || withVideo.Images[4].Position != 4 {
t.Fatalf("video not stored: %+v", withVideo.Images[4])
}
twoVideos := &Post{
AuthorID: homeowner.ID,
Title: "Two clips",
Body: "Not allowed.",
Images: []PostImage{
{ObjectKey: "posts/a.mp4", PublicURL: "https://cdn.example/a.mp4", Kind: MediaKindVideo},
{ObjectKey: "posts/b.mp4", PublicURL: "https://cdn.example/b.mp4", Kind: MediaKindVideo},
},
}
if err := mem.CreatePost(ctx, twoVideos); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("two-video create error = %v, want ErrInvalidPost", err)
}
} }
func validPostImages(count int) []PostImage { func validPostImages(count int) []PostImage {
+1
View File
@@ -35,6 +35,7 @@ type PostImage struct {
ObjectKey string ObjectKey string
PublicUrl string PublicUrl string
Description string Description string
Kind string
Position int16 Position int16
Width int32 Width int32
Height int32 Height int32
+9 -4
View File
@@ -59,7 +59,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
const createPostImage = `-- name: CreatePostImage :exec const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images ( INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at id, post_id, object_key, public_url, description, kind, position, width, height, created_at
) )
VALUES ( VALUES (
$1, $1,
@@ -70,7 +70,8 @@ VALUES (
$6, $6,
$7, $7,
$8, $8,
$9 $9,
$10
) )
` `
@@ -80,6 +81,7 @@ type CreatePostImageParams struct {
ObjectKey string ObjectKey string
PublicUrl string PublicUrl string
Description string Description string
Kind string
Position int16 Position int16
Width int32 Width int32
Height int32 Height int32
@@ -93,6 +95,7 @@ func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams
arg.ObjectKey, arg.ObjectKey,
arg.PublicUrl, arg.PublicUrl,
arg.Description, arg.Description,
arg.Kind,
arg.Position, arg.Position,
arg.Width, arg.Width,
arg.Height, arg.Height,
@@ -201,7 +204,7 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
const listPostImages = `-- name: ListPostImages :many const listPostImages = `-- name: ListPostImages :many
SELECT SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at id, post_id, object_key, public_url, description, kind, position, width, height, created_at
FROM post_images FROM post_images
WHERE post_id = $1 WHERE post_id = $1
ORDER BY position ORDER BY position
@@ -222,6 +225,7 @@ func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImag
&i.ObjectKey, &i.ObjectKey,
&i.PublicUrl, &i.PublicUrl,
&i.Description, &i.Description,
&i.Kind,
&i.Position, &i.Position,
&i.Width, &i.Width,
&i.Height, &i.Height,
@@ -327,7 +331,7 @@ WITH RECURSIVE thread AS (
) )
SELECT SELECT
images.id, images.post_id, images.object_key, images.public_url, images.id, images.post_id, images.object_key, images.public_url,
images.description, images.position, images.width, images.height, images.created_at images.description, images.kind, images.position, images.width, images.height, images.created_at
FROM post_images images FROM post_images images
JOIN thread ON thread.id = images.post_id JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position ORDER BY images.post_id, images.position
@@ -348,6 +352,7 @@ func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]Po
&i.ObjectKey, &i.ObjectKey,
&i.PublicUrl, &i.PublicUrl,
&i.Description, &i.Description,
&i.Kind,
&i.Position, &i.Position,
&i.Width, &i.Width,
&i.Height, &i.Height,
+3
View File
@@ -55,6 +55,9 @@ func (s *Server) postEvent(post, root *store.Post, author *store.User) events.Po
if n := len(post.Images); n > 0 { if n := len(post.Images); n > 0 {
ev.Images = make([]events.Image, 0, n) ev.Images = make([]events.Image, 0, n)
for _, img := range post.Images { for _, img := range post.Images {
if img.Kind == store.MediaKindVideo {
continue
}
ev.Images = append(ev.Images, events.Image{ ev.Images = append(ev.Images, events.Image{
URL: img.PublicURL, URL: img.PublicURL,
Description: img.Description, Description: img.Description,
+18
View File
@@ -236,3 +236,21 @@ func assertPostEvent(t *testing.T, got, want events.PostEvent) {
t.Fatalf("event = %+v, want %+v", got, want) t.Fatalf("event = %+v, want %+v", got, want)
} }
} }
func TestPostEventOmitsVideos(t *testing.T) {
t.Parallel()
srv, _ := newTestServer(t, Config{})
got := srv.postEvent(&store.Post{
ID: "root-1",
Title: "Clip",
Body: "Photo and video.",
Images: []store.PostImage{
{PublicURL: "https://cdn.example/a.jpg", Description: "Still", Kind: store.MediaKindImage},
{PublicURL: "https://cdn.example/a.mp4", Description: "Walkthrough", Kind: store.MediaKindVideo},
},
}, nil, nil)
if len(got.Images) != 1 || got.Images[0].URL != "https://cdn.example/a.jpg" {
t.Fatalf("event images = %+v", got.Images)
}
}
+174 -18
View File
@@ -28,7 +28,8 @@ import (
const ( const (
defaultRequestBodyBytes = 3 << 20 defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20 postImageMaxFileBytes = 5 << 20
postImageMaxRequestBytes = 22 << 20 postVideoMaxFileBytes = 25 << 20
postImageMaxRequestBytes = 50 << 20 // 4 images + 1 video + form fields
postImageMultipartMemory = 2 << 20 postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000 postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000 postImageMaxSourcePixels = 16_000_000
@@ -67,7 +68,7 @@ func parsePostMutationForm(w http.ResponseWriter, r *http.Request) (func(), bool
contentType := r.Header.Get("Content-Type") contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType) mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") { if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
http.Error(w, "Could not read image upload.", http.StatusBadRequest) http.Error(w, "Could not read upload.", http.StatusBadRequest)
return func() {}, false return func() {}, false
} }
if mediaType != "multipart/form-data" { if mediaType != "multipart/form-data" {
@@ -97,10 +98,10 @@ func writePostImageRequestError(w http.ResponseWriter, err error) {
} }
var maxErr *http.MaxBytesError var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) { if errors.As(err, &maxErr) {
http.Error(w, "Image upload is too large.", http.StatusRequestEntityTooLarge) http.Error(w, "Upload is too large.", http.StatusRequestEntityTooLarge)
return return
} }
http.Error(w, "Could not read image upload.", http.StatusBadRequest) http.Error(w, "Could not read upload.", http.StatusBadRequest)
} }
func (s *Server) postImagesFromForm( func (s *Server) postImagesFromForm(
@@ -121,9 +122,23 @@ func (s *Server) postImagesFromForm(
if len(descriptions) > len(files) { if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil) return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
} }
if len(retained)+len(files) > store.MaxPostImages { kinds := make([]string, len(files))
newImages, newVideos := 0, 0
for i, header := range files {
kinds[i] = sniffPostMedia(header)
if kinds[i] == store.MediaKindVideo {
newVideos++
} else {
newImages++
}
}
retainedImages, retainedVideos := countPostMedia(retained)
if retainedImages+newImages > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil) return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
} }
if retainedVideos+newVideos > store.MaxPostVideos {
return nil, nil, invalidPostImage("You can attach one video.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() { if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{ return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable, status: http.StatusServiceUnavailable,
@@ -142,13 +157,41 @@ func (s *Server) postImagesFromForm(
s.deletePostImageObjects(newKeys) s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil) return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
} }
prepared, err := preparePostImage(header) item, objectKey, err := s.uploadPostMedia(ctx, postID, header, kinds[i], description)
if err != nil { if err != nil {
s.deletePostImageObjects(newKeys) s.deletePostImageObjects(newKeys)
return nil, nil, err return nil, nil, err
} }
imageID := uuid.NewString() newKeys = append(newKeys, objectKey)
objectKey := path.Join("post-images", postID, imageID+prepared.extension) images = append(images, item)
}
return images, newKeys, nil
}
func countPostMedia(items []store.PostImage) (images, videos int) {
for _, item := range items {
if item.Kind == store.MediaKindVideo {
videos++
} else {
images++
}
}
return images, videos
}
func (s *Server) uploadPostMedia(
ctx context.Context,
postID string,
header *multipart.FileHeader,
kind, description string,
) (store.PostImage, string, error) {
if kind == store.MediaKindVideo {
prepared, err := preparePostVideo(header)
if err != nil {
return store.PostImage{}, "", err
}
mediaID := uuid.NewString()
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{ publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey, Key: objectKey,
Body: bytes.NewReader(prepared.body), Body: bytes.NewReader(prepared.body),
@@ -156,25 +199,50 @@ func (s *Server) postImagesFromForm(
Size: int64(len(prepared.body)), Size: int64(len(prepared.body)),
}) })
if err != nil { if err != nil {
s.deletePostImageObjects(newKeys) return store.PostImage{}, "", &postImageRequestError{
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable, status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.", message: "Could not upload video. Try again later.",
cause: err, cause: err,
} }
} }
newKeys = append(newKeys, objectKey) return store.PostImage{
images = append(images, store.PostImage{ ID: mediaID,
ID: imageID,
PostID: postID, PostID: postID,
ObjectKey: objectKey, ObjectKey: objectKey,
PublicURL: publicURL, PublicURL: publicURL,
Description: description, Description: description,
Width: prepared.width, Kind: store.MediaKindVideo,
Height: prepared.height, }, objectKey, nil
})
} }
return images, newKeys, nil prepared, err := preparePostImage(header)
if err != nil {
return store.PostImage{}, "", err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
ContentType: prepared.contentType,
Size: int64(len(prepared.body)),
})
if err != nil {
return store.PostImage{}, "", &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
return store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Kind: store.MediaKindImage,
Width: prepared.width,
Height: prepared.height,
}, objectKey, nil
} }
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) { func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
@@ -298,6 +366,94 @@ func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
return result, nil return result, nil
} }
func sniffPostMedia(header *multipart.FileHeader) string {
if header == nil {
return store.MediaKindImage
}
file, err := header.Open()
if err != nil {
return store.MediaKindImage
}
defer file.Close()
peek := make([]byte, 512)
n, err := io.ReadFull(file, peek)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return store.MediaKindImage
}
return mediaKindFromBytes(peek[:n])
}
func mediaKindFromBytes(raw []byte) string {
switch http.DetectContentType(raw) {
case "video/mp4", "video/webm":
return store.MediaKindVideo
case "image/jpeg", "image/png", "image/webp":
return store.MediaKindImage
}
if isMP4(raw) || isWebM(raw) {
return store.MediaKindVideo
}
return store.MediaKindImage
}
func isMP4(raw []byte) bool {
return len(raw) >= 8 && string(raw[4:8]) == "ftyp"
}
func isWebM(raw []byte) bool {
return len(raw) >= 4 && raw[0] == 0x1a && raw[1] == 0x45 && raw[2] == 0xdf && raw[3] == 0xa3
}
type preparedPostVideo struct {
body []byte
extension string
contentType string
}
func preparePostVideo(header *multipart.FileHeader) (preparedPostVideo, error) {
if header == nil {
return preparedPostVideo{}, invalidPostImage("Select a valid video.", nil)
}
if header.Size > postVideoMaxFileBytes {
return preparedPostVideo{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each video must be 25 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postVideoMaxFileBytes+1))
if err != nil {
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
if len(raw) == 0 {
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
}
if int64(len(raw)) > postVideoMaxFileBytes {
return preparedPostVideo{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each video must be 25 MB or smaller.",
}
}
switch mediaKindFromBytes(raw) {
case store.MediaKindVideo:
default:
return preparedPostVideo{}, invalidPostImage("Videos must be MP4 or WebM.", nil)
}
result := preparedPostVideo{body: raw}
if isWebM(raw) {
result.extension = ".webm"
result.contentType = "video/webm"
return result, nil
}
result.extension = ".mp4"
result.contentType = "video/mp4"
return result, nil
}
func jpegOrientation(raw []byte) int { func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw)) metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil { if err != nil {
+139
View File
@@ -12,6 +12,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"sync" "sync"
"testing" "testing"
@@ -56,6 +57,33 @@ func TestPreparePostImage(t *testing.T) {
} }
} }
func TestPreparePostVideo(t *testing.T) {
t.Parallel()
prepared, err := preparePostVideoHeader(t, "clip.mp4", tinyMP4())
if err != nil {
t.Fatal(err)
}
if prepared.extension != ".mp4" || prepared.contentType != "video/mp4" || len(prepared.body) == 0 {
t.Fatalf("prepared MP4 = %+v", prepared)
}
prepared, err = preparePostVideoHeader(t, "clip.webm", tinyWebM())
if err != nil {
t.Fatal(err)
}
if prepared.extension != ".webm" || prepared.contentType != "video/webm" {
t.Fatalf("prepared WebM = %+v", prepared)
}
if _, err := preparePostVideoHeader(t, "notes.txt", []byte("not a video")); err == nil {
t.Fatal("text video upload unexpectedly succeeded")
}
_, err = preparePostVideoHeader(t, "too-large.mp4", make([]byte, postVideoMaxFileBytes+1))
var requestErr *postImageRequestError
if !errors.As(err, &requestErr) || requestErr.status != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized video error = %v, want 413 request error", err)
}
}
func TestOrientPostImage(t *testing.T) { func TestOrientPostImage(t *testing.T) {
t.Parallel() t.Parallel()
@@ -203,6 +231,76 @@ func TestPostImageMultipartLifecycle(t *testing.T) {
} }
} }
func TestPostVideoMultipartLifecycle(t *testing.T) {
t.Parallel()
blobs := &recordingImageBlob{}
srv, mem := newTestServer(t, Config{Blob: blobs})
handler := srv.Handler()
homeowner := seedUser(t, mem, uniq("video"), "hunter22", store.RoleUser)
cookies := loginUser(t, handler, homeowner.Username, "hunter22")
csrf := csrfForCookies(t, handler, cookies)
rec := multipartPost(t, handler, "/submit", map[string][]string{
"_csrf": {csrf},
"title": {"Valve clip"},
"body": {"A photo and a video."},
"city": {"Oakland"},
"image_description": {"Still", "Walkthrough"},
}, []multipartTestFile{
{name: "still.png", body: solidPNG(t, 40, 20)},
{name: "walk.mp4", body: tinyMP4()},
}, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("root video upload status = %d: %s", rec.Code, rec.Body.String())
}
roots, err := mem.ListRootPosts(context.Background(), pacific.Today(), homeowner.ID)
if err != nil || len(roots) != 1 {
t.Fatalf("roots = %+v, %v", roots, err)
}
root, err := mem.GetPost(context.Background(), roots[0].ID)
if err != nil {
t.Fatal(err)
}
if len(root.Images) != 2 ||
root.Images[0].Kind != store.MediaKindImage ||
root.Images[1].Kind != store.MediaKindVideo ||
root.Images[1].Description != "Walkthrough" ||
!strings.HasPrefix(root.Images[1].ObjectKey, "post-videos/") {
t.Fatalf("root media = %+v", root.Images)
}
rec = multipartPost(t, handler, "/posts/"+root.ID+"/edit", map[string][]string{
"_csrf": {csrf},
"body": {"Keep the clip."},
"existing_image_id": {root.Images[1].ID},
"existing_image_description": {"Kept clip"},
}, nil, cookies)
if rec.Code != http.StatusSeeOther {
t.Fatalf("retain video status = %d: %s", rec.Code, rec.Body.String())
}
edited, err := mem.GetPost(context.Background(), root.ID)
if err != nil {
t.Fatal(err)
}
if len(edited.Images) != 1 || edited.Images[0].Kind != store.MediaKindVideo ||
edited.Images[0].Description != "Kept clip" {
t.Fatalf("retained video = %+v", edited.Images)
}
rec = multipartPost(t, handler, "/posts", map[string][]string{
"_csrf": {csrf},
"parent_id": {root.ID},
"body": {"Two clips."},
}, []multipartTestFile{
{name: "a.mp4", body: tinyMP4()},
{name: "b.mp4", body: tinyMP4()},
}, cookies)
if rec.Code != http.StatusBadRequest {
t.Fatalf("two-video status = %d, want 400", rec.Code)
}
}
func TestPostImageUploadCompensation(t *testing.T) { func TestPostImageUploadCompensation(t *testing.T) {
t.Parallel() t.Parallel()
@@ -318,6 +416,12 @@ func multipartPost(
return rec return rec
} }
func preparePostVideoHeader(t *testing.T, name string, body []byte) (preparedPostVideo, error) {
t.Helper()
header := multipartFileHeader(t, name, body)
return preparePostVideo(header)
}
func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) { func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPostImage, error) {
t.Helper() t.Helper()
var requestBody bytes.Buffer var requestBody bytes.Buffer
@@ -341,6 +445,41 @@ func preparePostImageHeader(t *testing.T, name string, body []byte) (preparedPos
return preparePostImage(req.MultipartForm.File["images"][0]) return preparePostImage(req.MultipartForm.File["images"][0])
} }
func multipartFileHeader(t *testing.T, name string, body []byte) *multipart.FileHeader {
t.Helper()
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("images", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(body); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/posts", &requestBody)
req.Header.Set("Content-Type", writer.FormDataContentType())
if err := req.ParseMultipartForm(postImageMultipartMemory); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = req.MultipartForm.RemoveAll() })
return req.MultipartForm.File["images"][0]
}
func tinyMP4() []byte {
body := make([]byte, 16)
body[3] = 16
copy(body[4:], "ftypisom")
copy(body[12:], "isom")
return body
}
func tinyWebM() []byte {
return []byte{0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00, 0x00, 0x00}
}
func solidPNG(t *testing.T, width, height int) []byte { func solidPNG(t *testing.T, width, height int) []byte {
t.Helper() t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height)) img := image.NewNRGBA(image.Rect(0, 0, width, height))
+13 -3
View File
@@ -48,13 +48,23 @@ CREATE TABLE IF NOT EXISTS post_images (
object_key TEXT NOT NULL UNIQUE, object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL, public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500), description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3), kind TEXT NOT NULL DEFAULT 'image',
width INTEGER NOT NULL CHECK (width > 0), position SMALLINT NOT NULL,
height INTEGER NOT NULL CHECK (height > 0), width INTEGER NOT NULL,
height INTEGER NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video')),
CONSTRAINT post_images_width_check CHECK (width >= 0),
CONSTRAINT post_images_height_check CHECK (height >= 0),
CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0)),
CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4),
UNIQUE (post_id, position) UNIQUE (post_id, position)
); );
CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx
ON post_images (post_id)
WHERE kind = 'video';
CREATE TABLE IF NOT EXISTS post_votes ( CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,