feat: implement resumable chunked media upload with progress indicators in admin UI
CI / test (push) Successful in 1m38s
CI / docker (push) Successful in 3m11s
CI / release (push) Skipped

This commit is contained in:
zwitschi committed 2026-08-04 20:21:53 +02:00
1 parent 930cdfdff9
commit 2c06265d11
6 files changed
+926 -14

No files matched your search

+532 -3
View File
@@ -195,6 +195,36 @@ type AdminMediaResponse struct {
GeneratedAt string `json:"generatedAt"`
}
type AdminMediaUploadInitRequest struct {
UploadID string `json:"uploadId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
MimeType string `json:"mimeType"`
ChunkSize int64 `json:"chunkSize"`
TotalChunks int `json:"totalChunks"`
}
type AdminMediaUploadSession struct {
UploadID string `json:"uploadId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
MimeType string `json:"mimeType"`
ChunkSize int64 `json:"chunkSize"`
TotalChunks int `json:"totalChunks"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type AdminMediaUploadStatusResponse struct {
UploadID string `json:"uploadId"`
FileName string `json:"fileName"`
FileSize int64 `json:"fileSize"`
ChunkSize int64 `json:"chunkSize"`
TotalChunks int `json:"totalChunks"`
UploadedChunks int `json:"uploadedChunks"`
Progress float64 `json:"progress"`
}
type AdminLoginPayload struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -209,6 +239,12 @@ var databaseDriverName = "sqlite"
const adminSessionCookieName = "linearvoid_admin_session"
const (
maxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
defaultAdminChunkSize int64 = 8 * 1024 * 1024
maxAdminChunkSize int64 = 32 * 1024 * 1024
)
// initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007)
func initRedis() {
redisAddress := os.Getenv("REDIS_URL")
@@ -938,6 +974,136 @@ func getMediaIDFromPath(path string) (uint, error) {
return uint(id), nil
}
func isAllowedMediaExtension(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
switch ext {
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
return true
default:
return false
}
}
func isAllowedMediaMime(mime string) bool {
mime = strings.TrimSpace(strings.ToLower(mime))
if mime == "" {
return true
}
if strings.HasPrefix(mime, "video/") {
return true
}
return mime == "application/octet-stream"
}
func getUploadWorkspaceDir() string {
return filepath.Join(getMediaDir(), ".uploads")
}
func getUploadSessionDir(uploadID string) string {
return filepath.Join(getUploadWorkspaceDir(), uploadID)
}
func getUploadSessionMetaPath(uploadID string) string {
return filepath.Join(getUploadSessionDir(uploadID), "session.json")
}
func getUploadChunkPath(uploadID string, chunkIndex int) string {
return filepath.Join(getUploadSessionDir(uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
}
func loadUploadSession(uploadID string) (AdminMediaUploadSession, error) {
var session AdminMediaUploadSession
data, err := os.ReadFile(getUploadSessionMetaPath(uploadID))
if err != nil {
return session, err
}
if err := json.Unmarshal(data, &session); err != nil {
return session, err
}
return session, nil
}
func saveUploadSession(session AdminMediaUploadSession) error {
if err := os.MkdirAll(getUploadSessionDir(session.UploadID), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return err
}
return os.WriteFile(getUploadSessionMetaPath(session.UploadID), data, 0o644)
}
func countUploadedChunks(uploadID string, totalChunks int) int {
uploaded := 0
for i := 0; i < totalChunks; i++ {
if _, err := os.Stat(getUploadChunkPath(uploadID, i)); err == nil {
uploaded++
}
}
return uploaded
}
func validateUploadID(uploadID string) bool {
if len(uploadID) < 16 || len(uploadID) > 128 {
return false
}
for _, r := range uploadID {
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
return false
}
}
return true
}
func buildUploadStatus(session AdminMediaUploadSession) AdminMediaUploadStatusResponse {
uploadedChunks := countUploadedChunks(session.UploadID, session.TotalChunks)
progress := 0.0
if session.TotalChunks > 0 {
progress = (float64(uploadedChunks) / float64(session.TotalChunks)) * 100
}
return AdminMediaUploadStatusResponse{
UploadID: session.UploadID,
FileName: session.FileName,
FileSize: session.FileSize,
ChunkSize: session.ChunkSize,
TotalChunks: session.TotalChunks,
UploadedChunks: uploadedChunks,
Progress: progress,
}
}
func uniqueMediaTargetPath(mediaDir, fileName string) string {
baseName := filepath.Base(fileName)
ext := filepath.Ext(baseName)
nameOnly := strings.TrimSuffix(baseName, ext)
candidate := filepath.Join(mediaDir, baseName)
for i := 1; ; i++ {
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
}
}
func detectMediaMime(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
head := make([]byte, 512)
n, err := f.Read(head)
if err != nil && err != io.EOF {
return "", err
}
return http.DetectContentType(head[:n]), nil
}
func mediaEntryFromRecord(record Media) MediaFileEntry {
return MediaFileEntry{
ID: record.ID,
@@ -1020,13 +1186,27 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
}
defer file.Close()
fileName := filepath.Base(strings.TrimSpace(header.Filename))
if fileName == "" {
http.Error(w, "missing file name", http.StatusBadRequest)
return
}
if !isAllowedMediaExtension(fileName) {
http.Error(w, "unsupported file type", http.StatusBadRequest)
return
}
if header.Size > maxAdminUploadBytes {
http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
return
}
mediaDir := getMediaDir()
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
return
}
targetPath := filepath.Join(mediaDir, filepath.Base(header.Filename))
targetPath := uniqueMediaTargetPath(mediaDir, fileName)
targetFile, err := os.Create(targetPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
@@ -1051,6 +1231,23 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
return
}
if fileInfo.Size() > maxAdminUploadBytes {
_ = os.Remove(targetPath)
http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
return
}
detectedMime, err := detectMediaMime(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
return
}
if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
return
}
durationSeconds, err := probeMediaDurationSeconds(targetPath)
if err != nil {
@@ -1059,10 +1256,10 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
return
}
filename := filepath.Base(header.Filename)
filename := filepath.Base(targetPath)
mediaRecord := Media{
Filename: filename,
OriginalName: header.Filename,
OriginalName: fileName,
Path: targetPath,
SizeBytes: fileInfo.Size(),
DurationSeconds: durationSeconds,
@@ -1089,6 +1286,336 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
}
}
func handleAdminMediaUploads(w http.ResponseWriter, r *http.Request) {
const basePath = "/api/admin/media/uploads"
if r.URL.Path == basePath {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload AdminMediaUploadInitRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
fileName := filepath.Base(strings.TrimSpace(payload.FileName))
if fileName == "" {
http.Error(w, "file name is required", http.StatusBadRequest)
return
}
if !isAllowedMediaExtension(fileName) {
http.Error(w, "unsupported file type", http.StatusBadRequest)
return
}
if payload.FileSize <= 0 {
http.Error(w, "file size must be positive", http.StatusBadRequest)
return
}
if payload.FileSize > maxAdminUploadBytes {
http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge)
return
}
if !isAllowedMediaMime(payload.MimeType) {
http.Error(w, "unsupported mime type", http.StatusBadRequest)
return
}
chunkSize := payload.ChunkSize
if chunkSize <= 0 {
chunkSize = defaultAdminChunkSize
}
if chunkSize > maxAdminChunkSize {
http.Error(w, "chunk size exceeds server limit", http.StatusBadRequest)
return
}
expectedChunks := int((payload.FileSize + chunkSize - 1) / chunkSize)
if payload.TotalChunks <= 0 {
payload.TotalChunks = expectedChunks
}
if payload.TotalChunks != expectedChunks {
http.Error(w, "total chunk count mismatch", http.StatusBadRequest)
return
}
uploadID := strings.TrimSpace(payload.UploadID)
if uploadID != "" && !validateUploadID(uploadID) {
http.Error(w, "invalid upload id", http.StatusBadRequest)
return
}
now := time.Now().UTC().Format(time.RFC3339)
session := AdminMediaUploadSession{}
if uploadID != "" {
loaded, err := loadUploadSession(uploadID)
if err == nil {
if loaded.FileName != fileName || loaded.FileSize != payload.FileSize {
http.Error(w, "upload id does not match selected file", http.StatusConflict)
return
}
session = loaded
} else if !os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("failed to resume upload session: %v", err), http.StatusInternalServerError)
return
}
}
if session.UploadID == "" {
randomID, err := generateSessionToken()
if err != nil {
http.Error(w, fmt.Sprintf("failed to create upload session: %v", err), http.StatusInternalServerError)
return
}
session = AdminMediaUploadSession{
UploadID: randomID,
FileName: fileName,
FileSize: payload.FileSize,
MimeType: payload.MimeType,
ChunkSize: chunkSize,
TotalChunks: payload.TotalChunks,
CreatedAt: now,
UpdatedAt: now,
}
} else {
session.UpdatedAt = now
}
if err := saveUploadSession(session); err != nil {
http.Error(w, fmt.Sprintf("failed to persist upload session: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(buildUploadStatus(session))
return
}
if !strings.HasPrefix(r.URL.Path, basePath+"/") {
http.NotFound(w, r)
return
}
remainder := strings.TrimPrefix(r.URL.Path, basePath+"/")
parts := strings.Split(strings.Trim(remainder, "/"), "/")
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
http.Error(w, "missing upload id", http.StatusBadRequest)
return
}
uploadID := strings.TrimSpace(parts[0])
if !validateUploadID(uploadID) {
http.Error(w, "invalid upload id", http.StatusBadRequest)
return
}
session, err := loadUploadSession(uploadID)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "upload session not found", http.StatusNotFound)
return
}
http.Error(w, fmt.Sprintf("failed to read upload session: %v", err), http.StatusInternalServerError)
return
}
if len(parts) == 1 {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(buildUploadStatus(session))
return
}
if len(parts) != 2 {
http.NotFound(w, r)
return
}
subresource := parts[1]
switch subresource {
case "chunk":
if r.Method != http.MethodPut {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
chunkIndexRaw := strings.TrimSpace(r.URL.Query().Get("index"))
chunkIndex, err := strconv.Atoi(chunkIndexRaw)
if err != nil || chunkIndex < 0 || chunkIndex >= session.TotalChunks {
http.Error(w, "invalid chunk index", http.StatusBadRequest)
return
}
chunkPath := getUploadChunkPath(uploadID, chunkIndex)
tmpPath := chunkPath + ".tmp"
chunkFile, err := os.Create(tmpPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to open chunk target: %v", err), http.StatusInternalServerError)
return
}
maxRead := session.ChunkSize + 1
written, copyErr := io.Copy(chunkFile, io.LimitReader(r.Body, maxRead))
closeErr := chunkFile.Close()
if copyErr != nil || closeErr != nil {
_ = os.Remove(tmpPath)
http.Error(w, "failed to persist upload chunk", http.StatusInternalServerError)
return
}
expectedMax := session.ChunkSize
if chunkIndex == session.TotalChunks-1 {
remaining := session.FileSize - (int64(chunkIndex) * session.ChunkSize)
if remaining > 0 {
expectedMax = remaining
}
}
if written <= 0 || written > expectedMax {
_ = os.Remove(tmpPath)
http.Error(w, "invalid chunk size", http.StatusBadRequest)
return
}
if err := os.Rename(tmpPath, chunkPath); err != nil {
_ = os.Remove(tmpPath)
http.Error(w, fmt.Sprintf("failed to finalize chunk: %v", err), http.StatusInternalServerError)
return
}
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if err := saveUploadSession(session); err != nil {
http.Error(w, fmt.Sprintf("failed to update upload session: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(buildUploadStatus(session))
case "complete":
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if countUploadedChunks(uploadID, session.TotalChunks) != session.TotalChunks {
http.Error(w, "upload is incomplete", http.StatusBadRequest)
return
}
mediaDir := getMediaDir()
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError)
return
}
targetPath := uniqueMediaTargetPath(mediaDir, session.FileName)
targetFile, err := os.Create(targetPath)
if err != nil {
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
return
}
var totalWritten int64
for i := 0; i < session.TotalChunks; i++ {
chunkPath := getUploadChunkPath(uploadID, i)
chunkReader, err := os.Open(chunkPath)
if err != nil {
targetFile.Close()
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("missing chunk %d", i), http.StatusBadRequest)
return
}
written, err := io.Copy(targetFile, chunkReader)
_ = chunkReader.Close()
if err != nil {
targetFile.Close()
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to assemble media file: %v", err), http.StatusInternalServerError)
return
}
totalWritten += written
}
if err := targetFile.Close(); err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError)
return
}
if totalWritten != session.FileSize {
_ = os.Remove(targetPath)
http.Error(w, "assembled file size mismatch", http.StatusBadRequest)
return
}
detectedMime, err := detectMediaMime(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest)
return
}
if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest)
return
}
durationSeconds, err := probeMediaDurationSeconds(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
return
}
fileInfo, err := os.Stat(targetPath)
if err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError)
return
}
filename := filepath.Base(targetPath)
mediaRecord := Media{
Filename: filename,
OriginalName: session.FileName,
Path: targetPath,
SizeBytes: fileInfo.Size(),
DurationSeconds: durationSeconds,
Status: "ready",
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
Tags: "",
}
if err := db.Create(&mediaRecord).Error; err != nil {
_ = os.Remove(targetPath)
http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError)
return
}
_ = os.RemoveAll(getUploadSessionDir(uploadID))
files, err := listMediaFiles()
if err != nil {
http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)})
default:
http.NotFound(w, r)
}
}
func handleAdminMediaByID(w http.ResponseWriter, r *http.Request) {
mediaID, err := getMediaIDFromPath(r.URL.Path)
if err != nil {
@@ -1424,6 +1951,8 @@ func main() {
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
http.HandleFunc("/api/admin/media/uploads", requireAdminAuth(handleAdminMediaUploads))
http.HandleFunc("/api/admin/media/uploads/", requireAdminAuth(handleAdminMediaUploads))
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
http.HandleFunc("/metrics", handleMetrics)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {