feat: implement resumable chunked media upload with progress indicators in admin UI
This commit is contained in:
1 parent
930cdfdff9
commit
2c06265d11
6 files changed
+926
-14
No files matched your search
+532
-3
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user