diff --git a/backend/api/upload_media.go b/backend/api/upload_media.go new file mode 100644 index 0000000..78aaf68 --- /dev/null +++ b/backend/api/upload_media.go @@ -0,0 +1,73 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "fr.latosa-escrima/api/core" + "fr.latosa-escrima/utils" +) + +func HandleUploadMedia(w http.ResponseWriter, r *http.Request) { + // Parse the multipart form + err := r.ParseMultipartForm(10 << 20) // Limit file size to 10 MB + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusRequestEntityTooLarge) + return + } + + // Retrieve the file from the form + file, fileHeader, err := r.FormFile("file") + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusBadRequest) + return + } + defer file.Close() + + // Create the destination file + if !utils.DoesPathExist("media") { + fmt.Println("Creating media forlder") + err = os.MkdirAll("media", os.ModePerm) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + } + p := filepath.Join("media", fileHeader.Filename) + dst, err := os.Create(p) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + defer dst.Close() + + // Copy the file content to the destination file + _, err = io.Copy(dst, file) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: "File uploaded successfully.", + }.Respond(w, http.StatusCreated) +} diff --git a/backend/api/verify_media.go b/backend/api/verify_media.go new file mode 100644 index 0000000..3f29876 --- /dev/null +++ b/backend/api/verify_media.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "path/filepath" + + "fr.latosa-escrima/api/core" + "fr.latosa-escrima/utils" +) + +const MAX_SIZE_BYTES = 10 * 1024 * 1024 + +type FileArgs struct { + Name string `json:"name"` + Type string `json:"type"` + SizeByte int64 `json:"size"` +} + +func HandleVerifyMedia(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + var file FileArgs + err = json.Unmarshal(body, &file) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + if utils.DoesPathExist(filepath.Join("media", file.Name)) { + core.JSONError{ + Status: core.Error, + Message: "File already exists.", + }.Respond(w, http.StatusInternalServerError) + return + } + + if file.SizeByte > MAX_SIZE_BYTES { + core.JSONError{ + Status: core.Error, + Message: "File is too big.", + }.Respond(w, http.StatusRequestEntityTooLarge) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: "File can be uploaded.", + }.Respond(w, http.StatusOK) + +} diff --git a/backend/go.mod b/backend/go.mod index 0290aaa..0720b0d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -19,7 +19,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect - github.com/uptrace/bun/extra/bundebug v1.2.8 // indirect + github.com/uptrace/bun/extra/bundebug v1.2.8 // direct github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/crypto v0.31.0 // indirect diff --git a/backend/main.go b/backend/main.go index 4459073..615135c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -97,10 +97,17 @@ func main() { // "/users/{user_uuid}/events/{event_uuid}": {Handler: nil, Middleware: nil}, // "/users/{user_uuid}/events/{event_uuid}/delete": {Handler: nil, Middleware: nil}, // "/users/{user_uuid}/events/{event_uuid}/update": {Handler: nil, Middleware: nil}, - "/blogs/new": {Handler: api.HandleCreateBlog, Middlewares: nil}, + "/blogs/new": {Handler: api.HandleCreateBlog, Middlewares: nil}, "/blogs/{uuid}": { - Handler: api.HandleGetBlog, - Middlewares: []core.Middleware{api.Methods("GET")}, }, + Handler: api.HandleGetBlog, + Middlewares: []core.Middleware{api.Methods("GET")}}, + "/media/upload": { + Handler: api.HandleUploadMedia, + Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}}, + "/media/verify": { + Handler: api.HandleVerifyMedia, + Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, + }, }) fmt.Printf("Serving on port %s\n", port) diff --git a/backend/utils/path_exists.go b/backend/utils/path_exists.go new file mode 100644 index 0000000..5552dc8 --- /dev/null +++ b/backend/utils/path_exists.go @@ -0,0 +1,11 @@ +package utils + +import "os" + +func DoesPathExist(path string) bool { + if _, err := os.Stat(path); err == nil { + return true + } + + return false +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 206c5cf..cbfda19 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,6 +18,8 @@ services: container_name: latosa-backend depends_on: - psql + volumes: + - ./backend/media:/media build: context: ./backend/ dockerfile: Dockerfile diff --git a/frontend/app/(auth)/dashboard/media/page.tsx b/frontend/app/(auth)/dashboard/media/page.tsx new file mode 100644 index 0000000..8d4a453 --- /dev/null +++ b/frontend/app/(auth)/dashboard/media/page.tsx @@ -0,0 +1,30 @@ +"use client"; +import useFileUpload from "@/hooks/use-file-upload"; +import { ChangeEvent } from "react"; + +const MyComponent = () => { + const { progress, isUploading, error, uploadFile, cancelUpload } = + useFileUpload(); + + const handleFileUpload = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + uploadFile(file, "/media/upload", (response) => { + console.log("Upload success:", response); + }); + } + }; + + return ( +
+ + {isUploading &&

Uploading... {progress}%

} + {error &&

Error: {error}

} + +
+ ); +}; + +export default MyComponent; diff --git a/frontend/hooks/use-file-upload.tsx b/frontend/hooks/use-file-upload.tsx new file mode 100644 index 0000000..806619f --- /dev/null +++ b/frontend/hooks/use-file-upload.tsx @@ -0,0 +1,120 @@ +"use client"; +import { API_URL } from "@/lib/constants"; +import { getCookie } from "cookies-next"; +import { useState, useRef, useCallback } from "react"; +import { ApiResponse, useApi } from "./use-api"; + +interface UseFileUploadReturn { + progress: number; + isUploading: boolean; + error: string | null; + uploadFile: ( + file: File, + url: string, + onSuccess?: (response: any) => void, + ) => void; + cancelUpload: () => void; +} + +const useFileUpload = (): UseFileUploadReturn => { + const [progress, setProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const xhrRef = useRef(null); + + const uploadFile = useCallback( + (file: File, url: string, onSuccess?: (response: any) => void) => { + url = `${API_URL}${url}`; + if (!file || !url) { + setError("File and upload URL are required."); + return; + } + const token = getCookie("auth_token"); + if (!token) { + setError("You're not logged in."); + return; + } + + fetch(`${API_URL}/media/verify`, { + method: "POST", + body: JSON.stringify({ + name: file.name, + size: file.size, + type: file.type, + }), + headers: { Authorization: `Bearer ${token}` }, + }) + .then(async (r) => { + const res: ApiResponse = await r.json(); + if (res.status === "Error" || r.status !== 200) { + setError(res.message); + return; + } + + const xhr = new XMLHttpRequest(); + xhrRef.current = xhr; + + xhr.open("POST", url); + + // Optional: Add headers if needed + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percentComplete = Math.round( + (event.loaded / event.total) * 100, + ); + setProgress(percentComplete); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + setProgress(100); + setIsUploading(false); + setError(null); + if (onSuccess) onSuccess(xhr.response); + } else { + setIsUploading(false); + setError( + `Upload failed with status ${xhr.status}: ${xhr.statusText}`, + ); + } + }; + + xhr.onerror = () => { + setIsUploading(false); + setError("An error occurred during the upload."); + }; + + xhr.onabort = () => { + setIsUploading(false); + setError("Upload aborted."); + }; + + setIsUploading(true); + setError(null); + setProgress(0); + + const formData = new FormData(); + formData.append("file", file); + + xhr.send(formData); + }) + .catch((err) => { + setError(err.message); + }); + }, + [], + ); + + const cancelUpload = useCallback(() => { + if (xhrRef.current) { + xhrRef.current.abort(); + } + }, []); + + return { progress, isUploading, error, uploadFile, cancelUpload }; +}; + +export default useFileUpload;