readysite / website / controllers / files.go
8.0 KB
files.go
package controllers

import (
	"fmt"
	"io"
	"net/http"
	"strings"

	"github.com/readysite/readysite/pkg/application"
	"github.com/readysite/readysite/website/internal/access"
	"github.com/readysite/readysite/website/internal/content"
	"github.com/readysite/readysite/website/internal/helpers"
	"github.com/readysite/readysite/website/models"
)

// Files returns the files controller.
func Files() (string, *FilesController) {
	return "files", &FilesController{}
}

// FilesController handles file upload and management.
type FilesController struct {
	application.BaseController
}

// Setup registers routes.
func (c *FilesController) Setup(app *application.App) {
	c.BaseController.Setup(app)

	// Public file serving by path (must come before ID-based routes)
	http.Handle("GET /_file/{path...}", app.Method(c, "ServeByPath", nil))

	// Public file serving by ID
	http.Handle("GET /files/{id}", app.Method(c, "Serve", nil))
	http.Handle("GET /files/{id}/{filename}", app.Method(c, "Serve", nil))
}

// Handle implements Controller interface with value receiver for request isolation.
func (c FilesController) Handle(r *http.Request) application.Controller {
	c.Request = r
	return &c
}

// File returns the current file from path parameter.
func (c *FilesController) File() *models.File {
	if c.Request == nil {
		return nil
	}
	id := c.PathValue("id")
	if id == "" {
		return nil
	}
	file, err := models.Files.Get(id)
	if err != nil {
		return nil
	}
	return file
}

// Pagination returns pagination state for the file list.
func (c *FilesController) Pagination() *helpers.Pagination {
	total := models.Files.Count("")
	return helpers.NewPagination(c.Request, total)
}

// AllFiles returns paginated files ordered by creation date.
func (c *FilesController) AllFiles() []*models.File {
	p := c.Pagination()
	files, _ := models.Files.Search("ORDER BY CreatedAt DESC LIMIT ? OFFSET ?", p.PageSize, p.Offset)
	return files
}

// FileCount returns the total number of files.
func (c *FilesController) FileCount() int {
	return models.Files.Count("")
}

// TotalSize returns the total size of all files.
func (c *FilesController) TotalSize() string {
	files, _ := models.Files.All()
	var total int64
	for _, f := range files {
		total += f.Size
	}
	return helpers.FormatBytes(total)
}

// Upload handles file upload.
func (c *FilesController) Upload(w http.ResponseWriter, r *http.Request) {
	// Rate limit by IP
	key := r.RemoteAddr
	if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
		key = ip
	}
	if !access.UploadLimiter.Allow(key) {
		c.RenderError(w, r, fmt.Errorf("Too many uploads. Please wait a moment."))
		return
	}

	// Limit upload size
	r.Body = http.MaxBytesReader(w, r.Body, content.MaxFileSize)

	// Parse multipart form
	if err := r.ParseMultipartForm(content.MaxFileSize); err != nil {
		c.RenderError(w, r, fmt.Errorf("File too large (max 10MB)"))
		return
	}

	// Get the file from the form
	file, header, err := r.FormFile("file")
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("No file provided"))
		return
	}
	defer file.Close()

	// Read file data
	data, err := io.ReadAll(file)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to read uploaded file: %w", err))
		return
	}

	// Validate upload using content package
	if err := content.ValidateUpload(header.Filename, int64(len(data)), data); err != nil {
		c.RenderError(w, r, err)
		return
	}

	// Detect MIME type
	mimeType := content.DetectMimeType(data)
	if mimeType == "application/octet-stream" {
		// Use header hint for files that can't be detected
		headerMime := header.Header.Get("Content-Type")
		if headerMime != "" && headerMime != "application/octet-stream" {
			mimeType = headerMime
		}
	}

	// Get current user
	user := access.GetUserFromJWT(r)
	userID := ""
	if user != nil {
		userID = user.ID
	}

	// Create file record
	f := &models.File{
		Name:     header.Filename,
		MimeType: mimeType,
		Size:     int64(len(data)),
		Data:     data,
		UserID:   userID,
	}

	fileID, err := models.Files.Insert(f)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to save file: %w", err))
		return
	}

	// Audit log
	helpers.AuditCreate(r, userID, helpers.ResourceFile, fileID, header.Filename)

	w.Header().Set("HX-Trigger", `{"showToast":"File uploaded successfully"}`)
	c.Refresh(w, r)
}

// Delete deletes a file.
func (c *FilesController) Delete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	file, err := models.Files.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("File not found"))
		return
	}

	// Delete any message attachments first
	mfs, _ := models.MessageFiles.Search("WHERE FileID = ?", file.ID)
	for _, mf := range mfs {
		models.MessageFiles.Delete(mf)
	}

	if err := models.Files.Delete(file); err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to delete file: %w", err))
		return
	}

	// Audit log
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}
	helpers.AuditDelete(r, userID, helpers.ResourceFile, id, file.Name)

	c.Redirect(w, r, "/admin/files")
}

// Serve serves a file's content by ID.
func (c *FilesController) Serve(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	file, err := models.Files.Get(id)
	if err != nil {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}

	// Set content type
	w.Header().Set("Content-Type", file.MimeType)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))

	// Set content disposition for downloads
	// SECURITY: Sanitize filename to prevent header injection
	disposition := "inline"
	if r.URL.Query().Get("download") == "1" {
		disposition = "attachment"
	}
	sanitizedName := content.SanitizeFilename(file.Name)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizedName))

	// Cache for 1 hour
	w.Header().Set("Cache-Control", "public, max-age=3600")

	w.Write(file.Data)
}

// ServeByPath serves a published file by its public path.
func (c *FilesController) ServeByPath(w http.ResponseWriter, r *http.Request) {
	path := r.PathValue("path")
	if path == "" {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}

	// Find file by path
	file, err := models.Files.First("WHERE Path = ? AND Published = ?", path, true)
	if err != nil || file == nil {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}

	// Set content type
	w.Header().Set("Content-Type", file.MimeType)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))

	// Set content disposition
	sanitizedName := content.SanitizeFilename(file.Name)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizedName))

	// Cache for 1 day (published files are more stable)
	w.Header().Set("Cache-Control", "public, max-age=86400")

	w.Write(file.Data)
}

// Update updates a file's publishing settings.
func (c *FilesController) Update(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	file, err := models.Files.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("File not found"))
		return
	}

	// Parse form
	if err := r.ParseForm(); err != nil {
		c.RenderError(w, r, fmt.Errorf("Invalid form data"))
		return
	}

	// Update Published
	file.Published = r.FormValue("published") == "true"

	// Update Path
	newPath := strings.TrimSpace(r.FormValue("path"))
	newPath = strings.TrimPrefix(newPath, "/")

	// Validate path uniqueness (only if publishing with a path)
	if file.Published && newPath != "" && newPath != file.Path {
		existing, _ := models.Files.First("WHERE Path = ? AND ID != ?", newPath, file.ID)
		if existing != nil {
			c.RenderError(w, r, fmt.Errorf("Path '%s' is already in use by another file", newPath))
			return
		}
	}

	file.Path = newPath

	if err := models.Files.Update(file); err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to update file: %w", err))
		return
	}

	// Audit log
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}
	helpers.AuditUpdate(r, userID, helpers.ResourceFile, id, file.Name, map[string]any{
		"published": file.Published,
		"path":      file.Path,
	})

	w.Header().Set("HX-Trigger", `{"showToast":"File updated successfully"}`)
	c.Redirect(w, r, "/admin/files/"+file.ID)
}
← Back