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)
}