Add post video upload (#22)
CI / test (push) Successful in 6m28s

Questions and replies can attach one MP4 or WebM (25 MB) alongside up to four images. Videos are stored as-is and omitted from Discord embeds.

Reviewed-on: #22
Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #22.
This commit is contained in:
2026-08-31 06:28:53 +00:00
committed by codegirl007
parent 728ae68811
commit a6e414853d
4 changed files with 334 additions and 18 deletions
+174 -18
View File
@@ -28,7 +28,8 @@ import (
const (
defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20
postImageMaxRequestBytes = 22 << 20
postVideoMaxFileBytes = 25 << 20
postImageMaxRequestBytes = 50 << 20 // 4 images + 1 video + form fields
postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000
@@ -67,7 +68,7 @@ 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)
http.Error(w, "Could not read upload.", http.StatusBadRequest)
return func() {}, false
}
if mediaType != "multipart/form-data" {
@@ -97,10 +98,10 @@ func writePostImageRequestError(w http.ResponseWriter, err error) {
}
var maxErr *http.MaxBytesError
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
}
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
http.Error(w, "Could not read upload.", http.StatusBadRequest)
}
func (s *Server) postImagesFromForm(
@@ -121,9 +122,23 @@ func (s *Server) postImagesFromForm(
if len(descriptions) > len(files) {
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)
}
if retainedVideos+newVideos > store.MaxPostVideos {
return nil, nil, invalidPostImage("You can attach one video.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
@@ -142,13 +157,41 @@ func (s *Server) postImagesFromForm(
s.deletePostImageObjects(newKeys)
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 {
s.deletePostImageObjects(newKeys)
return nil, nil, err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
newKeys = append(newKeys, objectKey)
images = append(images, item)
}
return images, newKeys, nil
}
func countPostMedia(items []store.PostImage) (images, videos int) {
for _, item := range items {
if item.Kind == store.MediaKindVideo {
videos++
} else {
images++
}
}
return images, videos
}
func (s *Server) uploadPostMedia(
ctx context.Context,
postID string,
header *multipart.FileHeader,
kind, description string,
) (store.PostImage, string, error) {
if kind == store.MediaKindVideo {
prepared, err := preparePostVideo(header)
if err != nil {
return store.PostImage{}, "", err
}
mediaID := uuid.NewString()
objectKey := path.Join("post-videos", postID, mediaID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
@@ -156,25 +199,50 @@ func (s *Server) postImagesFromForm(
Size: int64(len(prepared.body)),
})
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, &postImageRequestError{
return store.PostImage{}, "", &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
message: "Could not upload video. Try again later.",
cause: err,
}
}
newKeys = append(newKeys, objectKey)
images = append(images, store.PostImage{
ID: imageID,
return store.PostImage{
ID: mediaID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Width: prepared.width,
Height: prepared.height,
})
Kind: store.MediaKindVideo,
}, 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) {
@@ -298,6 +366,94 @@ func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
return result, nil
}
func sniffPostMedia(header *multipart.FileHeader) string {
if header == nil {
return store.MediaKindImage
}
file, err := header.Open()
if err != nil {
return store.MediaKindImage
}
defer file.Close()
peek := make([]byte, 512)
n, err := io.ReadFull(file, peek)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return store.MediaKindImage
}
return mediaKindFromBytes(peek[:n])
}
func mediaKindFromBytes(raw []byte) string {
switch http.DetectContentType(raw) {
case "video/mp4", "video/webm":
return store.MediaKindVideo
case "image/jpeg", "image/png", "image/webp":
return store.MediaKindImage
}
if isMP4(raw) || isWebM(raw) {
return store.MediaKindVideo
}
return store.MediaKindImage
}
func isMP4(raw []byte) bool {
return len(raw) >= 8 && string(raw[4:8]) == "ftyp"
}
func isWebM(raw []byte) bool {
return len(raw) >= 4 && raw[0] == 0x1a && raw[1] == 0x45 && raw[2] == 0xdf && raw[3] == 0xa3
}
type preparedPostVideo struct {
body []byte
extension string
contentType string
}
func preparePostVideo(header *multipart.FileHeader) (preparedPostVideo, error) {
if header == nil {
return preparedPostVideo{}, invalidPostImage("Select a valid video.", nil)
}
if header.Size > postVideoMaxFileBytes {
return preparedPostVideo{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each video must be 25 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postVideoMaxFileBytes+1))
if err != nil {
return preparedPostVideo{}, invalidPostImage("Could not read video.", err)
}
if len(raw) == 0 {
return preparedPostVideo{}, invalidPostImage("Videos cannot be empty.", nil)
}
if int64(len(raw)) > postVideoMaxFileBytes {
return preparedPostVideo{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each video must be 25 MB or smaller.",
}
}
switch mediaKindFromBytes(raw) {
case store.MediaKindVideo:
default:
return preparedPostVideo{}, invalidPostImage("Videos must be MP4 or WebM.", nil)
}
result := preparedPostVideo{body: raw}
if isWebM(raw) {
result.extension = ".webm"
result.contentType = "video/webm"
return result, nil
}
result.extension = ".mp4"
result.contentType = "video/mp4"
return result, nil
}
func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil {