From 2b882cedbd722b816c95f2dd0ed80335c50839bf Mon Sep 17 00:00:00 2001 From: Kanatello7 Date: Mon, 15 Dec 2025 18:06:13 +0500 Subject: [PATCH 1/6] updated handler_upload --- handler_upload_thumbnail.go | 54 +++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/handler_upload_thumbnail.go b/handler_upload_thumbnail.go index 765d87d3..f36482ae 100644 --- a/handler_upload_thumbnail.go +++ b/handler_upload_thumbnail.go @@ -2,13 +2,18 @@ package main import ( "fmt" + "io" "net/http" "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" "github.com/google/uuid" ) func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Request) { + type response struct { + database.Video + } videoIDString := r.PathValue("videoID") videoID, err := uuid.Parse(videoIDString) if err != nil { @@ -28,10 +33,55 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ return } - fmt.Println("uploading thumbnail for video", videoID, "by user", userID) // TODO: implement the upload here + const maxMemory = 10 << 20 // 10 MB + r.ParseMultipartForm(maxMemory) + + file, header, err := r.FormFile("thumbnail") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Unable to parse form file", err) + return + } + defer file.Close() + + mediaType := header.Header.Get("Content-Type") + if mediaType == "" { + respondWithError(w, http.StatusBadRequest, "Missing Content-Type for thumbnail", nil) + return + } + + data, err := io.ReadAll(file) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Error reading file", err) + return + } + + video, err := cfg.db.GetVideo(videoID) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't find video", err) + return + } + if video.UserID != userID { + respondWithError(w, http.StatusUnauthorized, "Not authorized to update this video", nil) + return + } + + videoThumbnails[videoID] = thumbnail{ + data: data, + mediaType: mediaType, + } + + url := fmt.Sprintf("http://localhost:%s/api/thumbnails/%s", cfg.port, videoID) + video.ThumbnailURL = &url + + err = cfg.db.UpdateVideo(video) + if err != nil { + delete(videoThumbnails, videoID) + respondWithError(w, http.StatusInternalServerError, "Couldn't update video", err) + return + } - respondWithJSON(w, http.StatusOK, struct{}{}) + respondWithJSON(w, http.StatusOK, video) } From 3c6a56e4d7f1670b91cf0c9362a01a2416778161 Mon Sep 17 00:00:00 2001 From: Kanatello7 Date: Mon, 15 Dec 2025 18:23:56 +0500 Subject: [PATCH 2/6] updated video handler --- handler_get_thumbnail.go | 31 ------------------------------- handler_upload_thumbnail.go | 11 ++++------- main.go | 5 +---- 3 files changed, 5 insertions(+), 42 deletions(-) diff --git a/handler_get_thumbnail.go b/handler_get_thumbnail.go index 1ddac141..06ab7d0f 100644 --- a/handler_get_thumbnail.go +++ b/handler_get_thumbnail.go @@ -1,32 +1 @@ package main - -import ( - "fmt" - "net/http" - - "github.com/google/uuid" -) - -func (cfg *apiConfig) handlerThumbnailGet(w http.ResponseWriter, r *http.Request) { - videoIDString := r.PathValue("videoID") - videoID, err := uuid.Parse(videoIDString) - if err != nil { - respondWithError(w, http.StatusBadRequest, "Invalid video ID", err) - return - } - - tn, ok := videoThumbnails[videoID] - if !ok { - respondWithError(w, http.StatusNotFound, "Thumbnail not found", nil) - return - } - - w.Header().Set("Content-Type", tn.mediaType) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(tn.data))) - - _, err = w.Write(tn.data) - if err != nil { - respondWithError(w, http.StatusInternalServerError, "Error writing response", err) - return - } -} diff --git a/handler_upload_thumbnail.go b/handler_upload_thumbnail.go index f36482ae..76ca948c 100644 --- a/handler_upload_thumbnail.go +++ b/handler_upload_thumbnail.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "io" "net/http" @@ -68,17 +69,13 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ return } - videoThumbnails[videoID] = thumbnail{ - data: data, - mediaType: mediaType, - } + base64Encoded := base64.StdEncoding.EncodeToString(data) + base64DataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, base64Encoded) - url := fmt.Sprintf("http://localhost:%s/api/thumbnails/%s", cfg.port, videoID) - video.ThumbnailURL = &url + video.ThumbnailURL = &base64DataURL err = cfg.db.UpdateVideo(video) if err != nil { - delete(videoThumbnails, videoID) respondWithError(w, http.StatusInternalServerError, "Couldn't update video", err) return } diff --git a/main.go b/main.go index a48f1405..e88bb83f 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "os" "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" - "github.com/google/uuid" "github.com/joho/godotenv" _ "github.com/lib/pq" @@ -29,8 +28,6 @@ type thumbnail struct { mediaType string } -var videoThumbnails = map[uuid.UUID]thumbnail{} - func main() { godotenv.Load(".env") @@ -119,7 +116,7 @@ func main() { mux.HandleFunc("POST /api/video_upload/{videoID}", cfg.handlerUploadVideo) mux.HandleFunc("GET /api/videos", cfg.handlerVideosRetrieve) mux.HandleFunc("GET /api/videos/{videoID}", cfg.handlerVideoGet) - mux.HandleFunc("GET /api/thumbnails/{videoID}", cfg.handlerThumbnailGet) + mux.HandleFunc("DELETE /api/videos/{videoID}", cfg.handlerVideoMetaDelete) mux.HandleFunc("POST /admin/reset", cfg.handlerReset) From 047b6ba61ce46450645e1d05de185a0716beb8e8 Mon Sep 17 00:00:00 2001 From: Kanatello7 Date: Mon, 15 Dec 2025 21:30:40 +0500 Subject: [PATCH 3/6] updated upload thumbnail handler to store files in filesystem --- assets.go | 26 ++++++++++++++++++++++++++ handler_upload_thumbnail.go | 29 +++++++++++++---------------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/assets.go b/assets.go index 8315787d..eea3a4a9 100644 --- a/assets.go +++ b/assets.go @@ -1,7 +1,12 @@ package main import ( + "fmt" "os" + "path/filepath" + "strings" + + "github.com/google/uuid" ) func (cfg apiConfig) ensureAssetsDir() error { @@ -10,3 +15,24 @@ func (cfg apiConfig) ensureAssetsDir() error { } return nil } + +func getAssetPath(videoID uuid.UUID, mediaType string) string { + ext := mediaTypeToExt(mediaType) + return fmt.Sprint("%s.%s", videoID, ext) +} + +func (cfg apiConfig) getAssetDiskPath(assetPath string) string { + return filepath.Join(cfg.assetsRoot, assetPath) +} + +func (cfg apiConfig) getAssetURL(assetPath string) string { + return fmt.Sprintf("http://localhost:%s/assets/%s", cfg.port, assetPath) +} + +func mediaTypeToExt(mediaType string) string { + parts := strings.Split(mediaType, "/") + if len(parts) < 2 { + return ".bin" + } + return "." + parts[1] +} diff --git a/handler_upload_thumbnail.go b/handler_upload_thumbnail.go index 76ca948c..83d021eb 100644 --- a/handler_upload_thumbnail.go +++ b/handler_upload_thumbnail.go @@ -1,20 +1,15 @@ package main import ( - "encoding/base64" - "fmt" "io" "net/http" + "os" "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" - "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" "github.com/google/uuid" ) func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Request) { - type response struct { - database.Video - } videoIDString := r.PathValue("videoID") videoID, err := uuid.Parse(videoIDString) if err != nil { @@ -34,9 +29,6 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ return } - fmt.Println("uploading thumbnail for video", videoID, "by user", userID) - - // TODO: implement the upload here const maxMemory = 10 << 20 // 10 MB r.ParseMultipartForm(maxMemory) @@ -53,9 +45,17 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ return } - data, err := io.ReadAll(file) + assetPath := getAssetPath(videoID, mediaType) + assetDiskPath := cfg.getAssetDiskPath(assetPath) + + dst, err := os.Create(assetDiskPath) if err != nil { - respondWithError(w, http.StatusInternalServerError, "Error reading file", err) + respondWithError(w, http.StatusInternalServerError, "Unable to create file on server", err) + return + } + defer dst.Close() + if _, err = io.Copy(dst, file); err != nil { + respondWithError(w, http.StatusInternalServerError, "Error saving file", err) return } @@ -69,11 +69,8 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ return } - base64Encoded := base64.StdEncoding.EncodeToString(data) - base64DataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, base64Encoded) - - video.ThumbnailURL = &base64DataURL - + url := cfg.getAssetURL(assetPath) + video.ThumbnailURL = &url err = cfg.db.UpdateVideo(video) if err != nil { respondWithError(w, http.StatusInternalServerError, "Couldn't update video", err) From 48a0696b657c375bcd884fd207d7597fa7e53f2a Mon Sep 17 00:00:00 2001 From: Kanatello7 Date: Mon, 15 Dec 2025 21:39:57 +0500 Subject: [PATCH 4/6] updated handler to accept only jpeg or png format --- handler_upload_thumbnail.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/handler_upload_thumbnail.go b/handler_upload_thumbnail.go index 83d021eb..72437b6e 100644 --- a/handler_upload_thumbnail.go +++ b/handler_upload_thumbnail.go @@ -2,6 +2,7 @@ package main import ( "io" + "mime" "net/http" "os" @@ -39,9 +40,13 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ } defer file.Close() - mediaType := header.Header.Get("Content-Type") - if mediaType == "" { - respondWithError(w, http.StatusBadRequest, "Missing Content-Type for thumbnail", nil) + mediaType, _, err := mime.ParseMediaType(header.Header.Get("Content-Type")) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid Content-Type", err) + return + } + if mediaType != "image/jpeg" && mediaType != "image/png" { + respondWithError(w, http.StatusBadRequest, "Invalid file type", nil) return } From 84f90b39b9be50f893558225edd68fd3a525e08e Mon Sep 17 00:00:00 2001 From: Kanatello7 Date: Tue, 16 Dec 2025 23:17:18 +0500 Subject: [PATCH 5/6] updated upload video handler --- assets.go | 19 ++++- cache.go | 4 +- go.mod | 19 +++++ go.sum | 40 ++++++++- handler_upload_thumbnail.go | 2 +- handler_upload_video.go | 158 +++++++++++++++++++++++++++++++++++- main.go | 17 ++-- 7 files changed, 243 insertions(+), 16 deletions(-) diff --git a/assets.go b/assets.go index eea3a4a9..581273aa 100644 --- a/assets.go +++ b/assets.go @@ -1,12 +1,12 @@ package main import ( + "crypto/rand" + "encoding/base64" "fmt" "os" "path/filepath" "strings" - - "github.com/google/uuid" ) func (cfg apiConfig) ensureAssetsDir() error { @@ -16,9 +16,20 @@ func (cfg apiConfig) ensureAssetsDir() error { return nil } -func getAssetPath(videoID uuid.UUID, mediaType string) string { +func getAssetPath(mediaType string) string { + base := make([]byte, 32) + _, err := rand.Read(base) + if err != nil { + panic("failed to generate random bytes") + } + id := base64.RawURLEncoding.EncodeToString(base) + ext := mediaTypeToExt(mediaType) - return fmt.Sprint("%s.%s", videoID, ext) + return fmt.Sprintf("%s%s", id, ext) +} + +func (cfg apiConfig) getObjectURL(key string) string { + return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", cfg.s3Bucket, cfg.s3Region, key) } func (cfg apiConfig) getAssetDiskPath(assetPath string) string { diff --git a/cache.go b/cache.go index 84ac0afb..6cdfaf48 100644 --- a/cache.go +++ b/cache.go @@ -2,9 +2,9 @@ package main import "net/http" -func cacheMiddleware(next http.Handler) http.Handler { +func noCacheMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "max-age=3600") + w.Header().Set("Cache-Control", "no-store") next.ServeHTTP(w, r) }) } diff --git a/go.mod b/go.mod index adbd9d7e..c7e2f391 100644 --- a/go.mod +++ b/go.mod @@ -16,5 +16,24 @@ require ( require ( github.com/alexedwards/argon2id v1.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index cef679a3..f3c1f3c3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,43 @@ github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= +github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= +github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs= github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -13,8 +51,6 @@ github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/handler_upload_thumbnail.go b/handler_upload_thumbnail.go index 72437b6e..f8edae61 100644 --- a/handler_upload_thumbnail.go +++ b/handler_upload_thumbnail.go @@ -50,7 +50,7 @@ func (cfg *apiConfig) handlerUploadThumbnail(w http.ResponseWriter, r *http.Requ return } - assetPath := getAssetPath(videoID, mediaType) + assetPath := getAssetPath(mediaType) assetDiskPath := cfg.getAssetDiskPath(assetPath) dst, err := os.Create(assetDiskPath) diff --git a/handler_upload_video.go b/handler_upload_video.go index 44e05497..d5b7a714 100644 --- a/handler_upload_video.go +++ b/handler_upload_video.go @@ -1,7 +1,163 @@ package main import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime" "net/http" + "os" + "os/exec" + "path" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/auth" + "github.com/google/uuid" ) -func (cfg *apiConfig) handlerUploadVideo(w http.ResponseWriter, r *http.Request) {} +func (cfg *apiConfig) handlerUploadVideo(w http.ResponseWriter, r *http.Request) { + const maxMemory = 1 << 30 + r.Body = http.MaxBytesReader(w, r.Body, maxMemory) + + videoIDString := r.PathValue("videoID") + videoID, err := uuid.Parse(videoIDString) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid ID", err) + return + } + token, err := auth.GetBearerToken(r.Header) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't find JWT", err) + return + } + + userID, err := auth.ValidateJWT(token, cfg.jwtSecret) + if err != nil { + respondWithError(w, http.StatusUnauthorized, "Couldn't validate JWT", err) + return + } + + video, err := cfg.db.GetVideo(videoID) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't find video", err) + return + } + if video.UserID != userID { + respondWithError(w, http.StatusUnauthorized, "Not authorized to update this video", nil) + return + } + + file, header, err := r.FormFile("video") + if err != nil { + respondWithError(w, http.StatusBadRequest, "Unable to parse form file", err) + return + } + defer file.Close() + + mediaType, _, err := mime.ParseMediaType(header.Header.Get("Content-Type")) + if err != nil { + respondWithError(w, http.StatusBadRequest, "Invalid Content-Type", err) + return + } + if mediaType != "video/mp4" { + respondWithError(w, http.StatusBadRequest, "Invalid file type", nil) + return + } + tmpFile, err := os.CreateTemp("", "tubely-upload-*.mp4") + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to create temp file", err) + return + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + if _, err := io.Copy(tmpFile, file); err != nil { + respondWithError(w, http.StatusInternalServerError, "Error saving file", err) + return + } + + if _, err := tmpFile.Seek(0, io.SeekStart); err != nil { + respondWithError(w, http.StatusInternalServerError, "Error seeking file", err) + return + } + + directory := "" + aspectRatio, err := getVideoAspectRatio(tmpFile.Name()) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Error determining aspect ratio", err) + return + } + switch aspectRatio { + case "16:9": + directory = "landscape" + case "9:16": + directory = "portrait" + default: + directory = "other" + } + + key := getAssetPath(mediaType) + key = path.Join(directory, key) + + _, err = cfg.s3Client.PutObject(r.Context(), &s3.PutObjectInput{ + Bucket: aws.String(cfg.s3Bucket), + Key: aws.String(key), + Body: tmpFile, + ContentType: aws.String(mediaType), + }) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to upload to S3", err) + return + } + url := cfg.getObjectURL(key) + video.VideoURL = &url + err = cfg.db.UpdateVideo(video) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Couldn't update video", err) + return + } + respondWithJSON(w, http.StatusOK, video) +} + +func getVideoAspectRatio(filePath string) (string, error) { + cmd := exec.Command("ffprobe", + "-v", "error", + "-print_format", "json", + "-show_streams", + filePath, + ) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("ffprobe error: %v", err) + } + + var output struct { + Streams []struct { + Width int `json:"width"` + Height int `json:"height"` + } `json:"streams"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + return "", fmt.Errorf("could not parse ffprobe output: %v", err) + } + + if len(output.Streams) == 0 { + return "", errors.New("no video streams found") + } + + width := output.Streams[0].Width + height := output.Streams[0].Height + + if width == 16*height/9 { + return "16:9", nil + } else if height == 16*width/9 { + return "9:16", nil + } + return "other", nil +} diff --git a/main.go b/main.go index e88bb83f..2dc2fa94 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( + "context" "log" "net/http" "os" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/bootdotdev/learn-file-storage-s3-golang-starter/internal/database" "github.com/joho/godotenv" @@ -20,14 +23,10 @@ type apiConfig struct { s3Bucket string s3Region string s3CfDistribution string + s3Client *s3.Client port string } -type thumbnail struct { - data []byte - mediaType string -} - func main() { godotenv.Load(".env") @@ -81,6 +80,11 @@ func main() { log.Fatal("PORT environment variable is not set") } + awsConfig, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(s3Region)) + if err != nil { + log.Fatal(err) + } + s3Client := s3.NewFromConfig(awsConfig) cfg := apiConfig{ db: db, jwtSecret: jwtSecret, @@ -90,6 +94,7 @@ func main() { s3Bucket: s3Bucket, s3Region: s3Region, s3CfDistribution: s3CfDistribution, + s3Client: s3Client, port: port, } @@ -103,7 +108,7 @@ func main() { mux.Handle("/app/", appHandler) assetsHandler := http.StripPrefix("/assets", http.FileServer(http.Dir(assetsRoot))) - mux.Handle("/assets/", cacheMiddleware(assetsHandler)) + mux.Handle("/assets/", noCacheMiddleware(assetsHandler)) mux.HandleFunc("POST /api/login", cfg.handlerLogin) mux.HandleFunc("POST /api/refresh", cfg.handlerRefresh) From d6cff67c2ed8fcd495cd27599f6380a533519c83 Mon Sep 17 00:00:00 2001 From: Kanatello7 Date: Tue, 16 Dec 2025 23:47:22 +0500 Subject: [PATCH 6/6] added processing video --- handler_upload_video.go | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/handler_upload_video.go b/handler_upload_video.go index d5b7a714..29a68ea4 100644 --- a/handler_upload_video.go +++ b/handler_upload_video.go @@ -99,13 +99,27 @@ func (cfg *apiConfig) handlerUploadVideo(w http.ResponseWriter, r *http.Request) directory = "other" } + processedFilePath, err := processVideoForFastStart(tmpFile.Name()) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Error processing video", err) + return + } + defer os.Remove(processedFilePath) + + processedFile, err := os.Open(processedFilePath) + if err != nil { + respondWithError(w, http.StatusInternalServerError, "Failed to open processed video", err) + return + } + defer processedFile.Close() + key := getAssetPath(mediaType) key = path.Join(directory, key) _, err = cfg.s3Client.PutObject(r.Context(), &s3.PutObjectInput{ Bucket: aws.String(cfg.s3Bucket), Key: aws.String(key), - Body: tmpFile, + Body: processedFile, ContentType: aws.String(mediaType), }) if err != nil { @@ -161,3 +175,25 @@ func getVideoAspectRatio(filePath string) (string, error) { } return "other", nil } + +func processVideoForFastStart(inputFilePath string) (string, error) { + processedFilePath := fmt.Sprintf("%s.processing", inputFilePath) + + cmd := exec.Command("ffmpeg", "-i", inputFilePath, "-movflags", "faststart", "-codec", "copy", "-f", "mp4", processedFilePath) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("error processing video: %s, %v", stderr.String(), err) + } + + fileInfo, err := os.Stat(processedFilePath) + if err != nil { + return "", fmt.Errorf("could not stat processed file: %v", err) + } + if fileInfo.Size() == 0 { + return "", fmt.Errorf("processed file is empty") + } + + return processedFilePath, nil +}