Compare commits

...

2 Commits

Author SHA1 Message Date
58a94dcd8e public URLs allowed 2026-04-07 01:18:16 -07:00
fcc30c92e2 seed data clearer API 2026-03-29 02:03:27 -07:00
3 changed files with 326 additions and 88 deletions

View File

@ -2,7 +2,7 @@
Yimaru Backend is the server-side application that powers the Yimaru online learning system. It manages courses, lessons, quizzes, student progress, instructor content, and administrative operations for institutions and users on the platform. Yimaru Backend is the server-side application that powers the Yimaru online learning system. It manages courses, lessons, quizzes, student progress, instructor content, and administrative operations for institutions and users on the platform.
## Table of Contents ## Table of Content
- [Installation](#installation) - [Installation](#installation)
- [Environment Configuration](#environment-configuration) - [Environment Configuration](#environment-configuration)

View File

@ -4,6 +4,7 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"bytes" "bytes"
"fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@ -23,6 +24,13 @@ type uploadMediaRes struct {
EmbedURL string `json:"embed_url,omitempty"` EmbedURL string `json:"embed_url,omitempty"`
} }
type uploadMediaByURLReq struct {
MediaType string `json:"media_type"`
SourceURL string `json:"source_url"`
Title string `json:"title"`
Description string `json:"description"`
}
// resolveFileURL converts a stored file path to a usable URL. // resolveFileURL converts a stored file path to a usable URL.
// If the path starts with "minio://", it generates a presigned URL. // If the path starts with "minio://", it generates a presigned URL.
// Otherwise it returns the path as-is (e.g. "/static/..."). // Otherwise it returns the path as-is (e.g. "/static/...").
@ -84,6 +92,24 @@ func (h *Handler) GetFileURL(c *fiber.Ctx) error {
// @Router /api/v1/files/upload [post] // @Router /api/v1/files/upload [post]
func (h *Handler) UploadMedia(c *fiber.Ctx) error { func (h *Handler) UploadMedia(c *fiber.Ctx) error {
mediaType := strings.ToLower(strings.TrimSpace(c.FormValue("media_type"))) mediaType := strings.ToLower(strings.TrimSpace(c.FormValue("media_type")))
sourceURL := strings.TrimSpace(c.FormValue("source_url"))
title := strings.TrimSpace(c.FormValue("title"))
description := strings.TrimSpace(c.FormValue("description"))
if strings.Contains(strings.ToLower(c.Get("Content-Type")), "application/json") {
var req uploadMediaByURLReq
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
mediaType = strings.ToLower(strings.TrimSpace(req.MediaType))
sourceURL = strings.TrimSpace(req.SourceURL)
title = strings.TrimSpace(req.Title)
description = strings.TrimSpace(req.Description)
}
if mediaType == "" { if mediaType == "" {
mediaType = "file" mediaType = "file"
} }
@ -94,14 +120,6 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
}) })
} }
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File is required",
Error: err.Error(),
})
}
maxSize := int64(100 * 1024 * 1024) // default 100MB maxSize := int64(100 * 1024 * 1024) // default 100MB
switch mediaType { switch mediaType {
case "image": case "image":
@ -111,13 +129,6 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
case "video": case "video":
maxSize = 500 * 1024 * 1024 maxSize = 500 * 1024 * 1024
} }
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "File exceeds size limit for selected media_type",
})
}
if mediaType == "video" && h.vimeoSvc == nil { if mediaType == "video" && h.vimeoSvc == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "Vimeo service is not available for video uploads", Message: "Vimeo service is not available for video uploads",
@ -129,85 +140,73 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
}) })
} }
fh, err := fileHeader.Open() var (
fileName string
contentType string
data []byte
)
if sourceURL != "" {
fetched, err := fetchMediaFromSourceURL(c.Context(), sourceURL, mediaType, maxSize)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to fetch source URL",
Error: err.Error(),
})
}
fileName = fetched.FileName
contentType = fetched.ContentType
data = fetched.Data
} else {
fileHeader, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File is required",
Error: err.Error(),
})
}
if fileHeader.Size > maxSize {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "File too large",
Error: "File exceeds size limit for selected media_type",
})
}
fh, err := fileHeader.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType = http.DetectContentType(head[:n])
rest, err := io.ReadAll(fh)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
data = append(head[:n], rest...)
fileName = fileHeader.Filename
}
var err error
contentType, err = normalizeAndValidateMediaContentType(mediaType, contentType, fileName)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Failed to read file", Message: "Invalid file type",
Error: err.Error(), Error: err.Error(),
}) })
} }
defer fh.Close()
head := make([]byte, 512)
n, _ := fh.Read(head)
contentType := http.DetectContentType(head[:n])
switch mediaType {
case "image":
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only jpg, png, and webp images are allowed",
})
}
case "audio":
allowedAudio := map[string]bool{
"audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "audio/mp4": true,
"audio/aac": true, "audio/webm": true, "video/ogg": true, "video/webm": true,
"audio/x-wav": true, "audio/x-m4a": true, "audio/flac": true,
}
// DetectContentType can return "application/octet-stream" for some files (often via clients
// like Postman). If that happens, fall back to extension-based detection.
if contentType == "application/octet-stream" {
filenameLower := strings.ToLower(fileHeader.Filename)
lastDot := strings.LastIndex(filenameLower, ".")
ext := ""
if lastDot != -1 && lastDot+1 < len(filenameLower) {
ext = filenameLower[lastDot+1:]
}
extMap := map[string]string{
"mp3": "audio/mpeg",
"wav": "audio/wav",
"ogg": "audio/ogg",
"m4a": "audio/mp4",
"aac": "audio/aac",
"webm": "audio/webm",
"flac": "audio/flac",
}
if ct, ok := extMap[ext]; ok {
contentType = ct
}
}
if !allowedAudio[contentType] {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only supported audio formats are allowed",
})
}
case "video":
if !strings.HasPrefix(contentType, "video/") {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid file type",
Error: "Only video files are allowed",
})
}
}
rest, err := io.ReadAll(fh)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to read file",
Error: err.Error(),
})
}
data := append(head[:n], rest...)
if mediaType == "video" { if mediaType == "video" {
title := strings.TrimSpace(c.FormValue("title"))
if title == "" { if title == "" {
title = fileHeader.Filename title = fileName
} }
description := strings.TrimSpace(c.FormValue("description"))
vimeoUpload, err := h.vimeoSvc.UploadVideoFile(c.Context(), title, description, bytes.NewReader(data), int64(len(data))) vimeoUpload, err := h.vimeoSvc.UploadVideoFile(c.Context(), title, description, bytes.NewReader(data), int64(len(data)))
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
@ -234,7 +233,7 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
uploadResult, err := h.minioSvc.Upload( uploadResult, err := h.minioSvc.Upload(
c.Context(), c.Context(),
mediaType, mediaType,
fileHeader.Filename, fileName,
bytes.NewReader(data), bytes.NewReader(data),
int64(len(data)), int64(len(data)),
contentType, contentType,
@ -261,6 +260,55 @@ func (h *Handler) UploadMedia(c *fiber.Ctx) error {
}) })
} }
func normalizeAndValidateMediaContentType(mediaType, contentType, fileName string) (string, error) {
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if contentType == "" {
contentType = "application/octet-stream"
}
switch mediaType {
case "image":
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return "", fmt.Errorf("only jpg, png, and webp images are allowed")
}
case "audio":
allowedAudio := map[string]bool{
"audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "audio/mp4": true,
"audio/aac": true, "audio/webm": true, "video/ogg": true, "video/webm": true,
"audio/x-wav": true, "audio/x-m4a": true, "audio/flac": true,
}
if contentType == "application/octet-stream" {
filenameLower := strings.ToLower(fileName)
lastDot := strings.LastIndex(filenameLower, ".")
ext := ""
if lastDot != -1 && lastDot+1 < len(filenameLower) {
ext = filenameLower[lastDot+1:]
}
extMap := map[string]string{
"mp3": "audio/mpeg",
"wav": "audio/wav",
"ogg": "audio/ogg",
"m4a": "audio/mp4",
"aac": "audio/aac",
"webm": "audio/webm",
"flac": "audio/flac",
}
if ct, ok := extMap[ext]; ok {
contentType = ct
}
}
if !allowedAudio[contentType] {
return "", fmt.Errorf("only supported audio formats are allowed")
}
case "video":
if !strings.HasPrefix(contentType, "video/") {
return "", fmt.Errorf("only video files are allowed")
}
}
return contentType, nil
}
// UploadAudio uploads an audio file to MinIO and returns the object key. // UploadAudio uploads an audio file to MinIO and returns the object key.
// @Summary Upload an audio file // @Summary Upload an audio file
// @Tags files // @Tags files

View File

@ -0,0 +1,190 @@
package handlers
import (
vimeopkg "Yimaru-Backend/internal/pkgs/vimeo"
"context"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"sort"
"strings"
"time"
)
type sourceFetchResult struct {
FileName string
ContentType string
Data []byte
}
func normalizeSourceURL(raw string, mediaType string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("invalid source URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", fmt.Errorf("source URL must use http or https")
}
host := strings.ToLower(u.Hostname())
if strings.Contains(host, "drive.google.com") {
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
// Convert /file/d/{id}/view links to a direct-download endpoint.
if len(parts) >= 3 && parts[0] == "file" && parts[1] == "d" {
fileID := parts[2]
return "https://drive.google.com/uc?export=download&id=" + fileID, nil
}
}
if mediaType == "video" && (strings.Contains(host, "vimeo.com") || strings.Contains(host, "player.vimeo.com")) {
directURL, err := resolveVimeoDirectFileURL(u.String())
if err != nil {
return "", err
}
if directURL != "" {
return directURL, nil
}
}
return u.String(), nil
}
func resolveVimeoDirectFileURL(vimeoURL string) (string, error) {
videoID := vimeopkg.ExtractVideoID(vimeoURL)
if videoID == "" {
return "", fmt.Errorf("failed to extract Vimeo video id from source URL")
}
src, err := url.Parse(vimeoURL)
if err != nil {
return "", fmt.Errorf("invalid Vimeo source URL: %w", err)
}
configURL := "https://player.vimeo.com/video/" + videoID + "/config"
if hash := src.Query().Get("h"); hash != "" {
configURL += "?h=" + url.QueryEscape(hash)
}
req, err := http.NewRequest(http.MethodGet, configURL, nil)
if err != nil {
return "", fmt.Errorf("failed to build Vimeo config request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Yimaru-Backend/1.0")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch Vimeo config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("Vimeo config fetch failed with status %d", resp.StatusCode)
}
var payload struct {
Request struct {
Files struct {
Progressive []struct {
URL string `json:"url"`
Height int `json:"height"`
MimeType string `json:"mime"`
} `json:"progressive"`
} `json:"files"`
} `json:"request"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("failed to decode Vimeo config payload: %w", err)
}
if len(payload.Request.Files.Progressive) == 0 {
return "", fmt.Errorf("no downloadable Vimeo progressive stream found; provide a Vimeo URL with a valid '?h=' hash or a direct file URL")
}
sort.Slice(payload.Request.Files.Progressive, func(i, j int) bool {
return payload.Request.Files.Progressive[i].Height > payload.Request.Files.Progressive[j].Height
})
return payload.Request.Files.Progressive[0].URL, nil
}
func fetchMediaFromSourceURL(ctx context.Context, rawSourceURL string, mediaType string, maxSize int64) (*sourceFetchResult, error) {
sourceURL, err := normalizeSourceURL(rawSourceURL, mediaType)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create source request: %w", err)
}
req.Header.Set("User-Agent", "Yimaru-Backend/1.0")
req.Header.Set("Accept", "*/*")
client := &http.Client{
Timeout: 2 * time.Minute,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch source URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, fmt.Errorf("source URL returned status %d", resp.StatusCode)
}
limited := io.LimitReader(resp.Body, maxSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, fmt.Errorf("failed to read source content: %w", err)
}
if int64(len(data)) > maxSize {
return nil, fmt.Errorf("source file exceeds size limit for media type '%s'", mediaType)
}
if len(data) == 0 {
return nil, fmt.Errorf("source URL returned empty content")
}
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType != "" {
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
}
if contentType == "" || contentType == "application/octet-stream" {
sampleLen := 512
if len(data) < sampleLen {
sampleLen = len(data)
}
contentType = http.DetectContentType(data[:sampleLen])
}
fileName := "source-upload"
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
if _, params, parseErr := mime.ParseMediaType(cd); parseErr == nil {
if fn := strings.TrimSpace(params["filename"]); fn != "" {
fileName = fn
}
}
}
if fileName == "source-upload" {
if parsedURL, parseErr := url.Parse(sourceURL); parseErr == nil {
base := path.Base(parsedURL.Path)
if base != "" && base != "." && base != "/" {
fileName = base
}
}
}
return &sourceFetchResult{
FileName: fileName,
ContentType: contentType,
Data: data,
}, nil
}