2026-03-18 14:13:52 +01:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-31 19:00:17 +02:00
|
|
|
"errors"
|
2026-03-18 14:13:52 +01:00
|
|
|
"fmt"
|
2026-03-21 12:34:31 +01:00
|
|
|
"image"
|
|
|
|
|
_ "image/gif"
|
|
|
|
|
_ "image/jpeg"
|
|
|
|
|
_ "image/png"
|
2026-03-31 19:00:17 +02:00
|
|
|
"io"
|
2026-03-18 14:13:52 +01:00
|
|
|
"log/slog"
|
2026-03-19 03:53:40 +01:00
|
|
|
"mime"
|
2026-03-18 14:13:52 +01:00
|
|
|
"net/http"
|
2026-03-31 19:00:17 +02:00
|
|
|
"path/filepath"
|
2026-03-18 14:13:52 +01:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/google/uuid"
|
2026-04-02 13:18:57 +02:00
|
|
|
"github.com/owncord/server/auth"
|
2026-03-18 14:13:52 +01:00
|
|
|
"github.com/owncord/server/db"
|
2026-04-02 11:16:16 +02:00
|
|
|
"github.com/owncord/server/permissions"
|
2026-04-05 21:31:35 +00:00
|
|
|
"github.com/owncord/server/service"
|
2026-03-18 14:13:52 +01:00
|
|
|
"github.com/owncord/server/storage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// uploadResponse is the JSON shape returned by POST /api/v1/uploads.
|
|
|
|
|
type uploadResponse struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Filename string `json:"filename"`
|
|
|
|
|
Size int64 `json:"size"`
|
|
|
|
|
Mime string `json:"mime"`
|
|
|
|
|
URL string `json:"url"`
|
2026-03-21 12:34:31 +01:00
|
|
|
Width *int `json:"width,omitempty"`
|
|
|
|
|
Height *int `json:"height,omitempty"`
|
2026-03-18 14:13:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:00:17 +02:00
|
|
|
// sanitizeUploadFilename cleans an upload filename: strips control characters,
|
|
|
|
|
// removes path separators, and truncates to a safe length.
|
|
|
|
|
func sanitizeUploadFilename(name string) string {
|
|
|
|
|
// Strip path components — use only the base name.
|
|
|
|
|
name = filepath.Base(name)
|
|
|
|
|
// Remove control characters.
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
for _, r := range name {
|
|
|
|
|
if r >= 32 && r != 127 { // exclude control chars and DEL
|
|
|
|
|
sb.WriteRune(r)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
name = strings.TrimSpace(sb.String())
|
|
|
|
|
// Truncate to 255 characters (filesystem limit).
|
2026-04-01 11:37:36 +02:00
|
|
|
if len(name) > maxUploadFilenameLength {
|
|
|
|
|
name = name[:maxUploadFilenameLength]
|
2026-03-31 19:00:17 +02:00
|
|
|
}
|
|
|
|
|
if name == "" || name == "." || name == ".." {
|
|
|
|
|
name = "unnamed"
|
|
|
|
|
}
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 13:23:09 +02:00
|
|
|
// isUnsafeInlineMIME returns true for MIME types that could execute active
|
|
|
|
|
// content (scripts, markup) if served inline under the OwnCord origin.
|
|
|
|
|
func isUnsafeInlineMIME(mimeType string) bool {
|
|
|
|
|
// Normalize: take the base type before any parameters (e.g. "text/html; charset=utf-8").
|
|
|
|
|
base := strings.SplitN(mimeType, ";", 2)[0]
|
|
|
|
|
base = strings.TrimSpace(strings.ToLower(base))
|
|
|
|
|
switch base {
|
|
|
|
|
case "text/html", "application/xhtml+xml",
|
|
|
|
|
"image/svg+xml", "text/xml", "application/xml",
|
|
|
|
|
"application/pdf",
|
|
|
|
|
"text/xsl", "text/xslt":
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:13:52 +01:00
|
|
|
// MountUploadRoutes registers upload and file-serving endpoints.
|
2026-03-29 12:35:04 +02:00
|
|
|
// allowedOrigins controls the Access-Control-Allow-Origin header on served files.
|
2026-04-06 13:48:41 +00:00
|
|
|
//
|
|
|
|
|
// permSvc MUST be non-nil — handleServeFile dereferences it to enforce
|
|
|
|
|
// per-channel ACLs on every file download. A nil permSvc would panic for
|
|
|
|
|
// any authenticated file request, so we fail fast at mount time rather
|
|
|
|
|
// than let the first user hit a 500.
|
2026-04-05 21:31:35 +00:00
|
|
|
func MountUploadRoutes(r chi.Router, database *db.DB, store *storage.Storage, limiter *auth.RateLimiter, allowedOrigins []string, permSvc *service.PermissionService) {
|
2026-04-06 13:48:41 +00:00
|
|
|
if permSvc == nil {
|
|
|
|
|
panic("api: MountUploadRoutes requires a non-nil PermissionService")
|
|
|
|
|
}
|
2026-03-18 14:13:52 +01:00
|
|
|
// Upload requires authentication and a higher body size limit (100 MB).
|
|
|
|
|
r.With(
|
|
|
|
|
AuthMiddleware(database),
|
2026-04-01 11:37:36 +02:00
|
|
|
MaxBodySize(uploadMaxBodySize),
|
2026-04-02 13:18:57 +02:00
|
|
|
).Post("/api/v1/uploads", handleUpload(database, store, limiter))
|
2026-04-02 11:16:16 +02:00
|
|
|
// File serving requires authentication for channel-level access control.
|
2026-04-05 21:31:35 +00:00
|
|
|
r.With(AuthMiddleware(database)).Get("/api/v1/files/{id}", handleServeFile(database, store, allowedOrigins, permSvc))
|
2026-03-18 14:13:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 13:18:57 +02:00
|
|
|
func handleUpload(database *db.DB, store *storage.Storage, limiter *auth.RateLimiter) http.HandlerFunc {
|
2026-03-18 14:13:52 +01:00
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2026-04-02 13:18:57 +02:00
|
|
|
// BUG-131: Per-user upload rate limit to prevent disk exhaustion.
|
|
|
|
|
user, ok := r.Context().Value(UserKey).(*db.User)
|
|
|
|
|
if ok && user != nil {
|
|
|
|
|
uploadKey := fmt.Sprintf("upload:%d", user.ID)
|
|
|
|
|
if !limiter.Allow(uploadKey, uploadRateLimitPerMinute, time.Minute) {
|
|
|
|
|
writeJSON(w, http.StatusTooManyRequests, errorResponse{
|
|
|
|
|
Error: "RATE_LIMITED",
|
|
|
|
|
Message: "upload rate limit exceeded, try again later",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 11:37:36 +02:00
|
|
|
// Limit request body size to prevent abuse.
|
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, uploadMaxBodySize)
|
|
|
|
|
|
2026-03-18 14:13:52 +01:00
|
|
|
// Parse multipart form — 10 MB in memory, rest on disk.
|
2026-04-01 11:37:36 +02:00
|
|
|
if err := r.ParseMultipartForm(multipartMemoryLimit); err != nil {
|
2026-03-31 18:46:33 +02:00
|
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
|
|
|
Error: "BAD_REQUEST",
|
|
|
|
|
Message: "invalid multipart form",
|
2026-03-18 14:13:52 +01:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file, header, err := r.FormFile("file")
|
|
|
|
|
if err != nil {
|
2026-03-31 18:46:33 +02:00
|
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
|
|
|
Error: "BAD_REQUEST",
|
|
|
|
|
Message: "missing file field",
|
2026-03-18 14:13:52 +01:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer file.Close() //nolint:errcheck
|
|
|
|
|
|
|
|
|
|
// Generate UUID for storage.
|
|
|
|
|
fileID := uuid.New().String()
|
|
|
|
|
|
2026-03-29 12:35:04 +02:00
|
|
|
// Detect MIME type from actual file bytes (never trust client header).
|
|
|
|
|
var sniffBuf [512]byte
|
|
|
|
|
n, readErr := file.Read(sniffBuf[:])
|
2026-03-31 19:00:17 +02:00
|
|
|
if readErr != nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
|
2026-03-31 18:46:33 +02:00
|
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
|
|
|
Error: "BAD_REQUEST",
|
|
|
|
|
Message: "failed to read uploaded file",
|
2026-03-29 12:35:04 +02:00
|
|
|
})
|
|
|
|
|
return
|
2026-03-18 14:13:52 +01:00
|
|
|
}
|
2026-03-29 12:35:04 +02:00
|
|
|
detectedMime := http.DetectContentType(sniffBuf[:n])
|
|
|
|
|
// Seek back so the full content is available for storage.
|
|
|
|
|
if _, seekErr := file.Seek(0, 0); seekErr != nil {
|
2026-03-31 18:46:33 +02:00
|
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
|
|
|
Error: "INTERNAL_ERROR",
|
|
|
|
|
Message: "failed to process uploaded file",
|
2026-03-29 12:35:04 +02:00
|
|
|
})
|
|
|
|
|
return
|
2026-03-18 14:13:52 +01:00
|
|
|
}
|
2026-03-29 12:35:04 +02:00
|
|
|
mime := detectedMime
|
2026-03-18 14:13:52 +01:00
|
|
|
|
|
|
|
|
// Store file on disk (validates file type via magic bytes).
|
2026-04-02 15:22:03 +02:00
|
|
|
writtenBytes, saveErr := store.Save(fileID, file)
|
|
|
|
|
if saveErr != nil {
|
|
|
|
|
slog.Warn("file upload rejected", "error", saveErr)
|
2026-03-31 18:46:33 +02:00
|
|
|
writeJSON(w, http.StatusBadRequest, errorResponse{
|
|
|
|
|
Error: "BAD_REQUEST",
|
2026-04-02 15:22:03 +02:00
|
|
|
Message: fmt.Sprintf("upload rejected: %s", saveErr),
|
2026-03-18 14:13:52 +01:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 12:34:31 +01:00
|
|
|
// Extract image dimensions if the file is an image.
|
|
|
|
|
var width, height *int
|
|
|
|
|
if strings.HasPrefix(mime, "image/") {
|
|
|
|
|
f, openErr := store.Open(fileID)
|
|
|
|
|
if openErr == nil {
|
|
|
|
|
cfg, _, decErr := image.DecodeConfig(f)
|
|
|
|
|
f.Close() //nolint:errcheck
|
|
|
|
|
if decErr == nil {
|
|
|
|
|
w2, h2 := cfg.Width, cfg.Height
|
|
|
|
|
width = &w2
|
|
|
|
|
height = &h2
|
|
|
|
|
} else {
|
|
|
|
|
slog.Warn("failed to decode image dimensions", "id", fileID, "error", decErr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:13:52 +01:00
|
|
|
// Insert attachment record in DB (unlinked — message_id is NULL).
|
2026-04-02 13:18:57 +02:00
|
|
|
user, _ = r.Context().Value(UserKey).(*db.User)
|
2026-03-31 19:00:17 +02:00
|
|
|
safeFilename := sanitizeUploadFilename(header.Filename)
|
2026-04-02 15:22:03 +02:00
|
|
|
if err := database.CreateAttachment(fileID, user.ID, safeFilename, fileID, mime, writtenBytes, width, height); err != nil {
|
2026-03-18 14:13:52 +01:00
|
|
|
// Clean up stored file on DB failure.
|
|
|
|
|
_ = store.Delete(fileID)
|
|
|
|
|
slog.Error("failed to create attachment record", "error", err)
|
2026-03-31 18:46:33 +02:00
|
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
|
|
|
Error: "INTERNAL_ERROR",
|
|
|
|
|
Message: "failed to save attachment",
|
2026-03-18 14:13:52 +01:00
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 15:22:03 +02:00
|
|
|
slog.Info("file uploaded", "id", fileID, "filename", safeFilename, "size", writtenBytes, "mime", mime)
|
2026-03-18 14:13:52 +01:00
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusCreated, uploadResponse{
|
|
|
|
|
ID: fileID,
|
2026-03-31 19:00:17 +02:00
|
|
|
Filename: safeFilename,
|
2026-04-02 15:22:03 +02:00
|
|
|
Size: writtenBytes,
|
2026-03-18 14:13:52 +01:00
|
|
|
Mime: mime,
|
|
|
|
|
URL: "/api/v1/files/" + fileID,
|
2026-03-21 12:34:31 +01:00
|
|
|
Width: width,
|
|
|
|
|
Height: height,
|
2026-03-18 14:13:52 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 21:31:35 +00:00
|
|
|
func handleServeFile(database *db.DB, store *storage.Storage, allowedOrigins []string, permSvc *service.PermissionService) http.HandlerFunc {
|
2026-03-18 14:13:52 +01:00
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
fileID := chi.URLParam(r, "id")
|
|
|
|
|
if fileID == "" {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:16:16 +02:00
|
|
|
user, _ := r.Context().Value(UserKey).(*db.User)
|
|
|
|
|
role, _ := r.Context().Value(RoleKey).(*db.Role)
|
|
|
|
|
|
|
|
|
|
// Look up attachment metadata with channel context.
|
|
|
|
|
aa, err := database.GetAttachmentWithChannel(fileID)
|
2026-03-18 14:13:52 +01:00
|
|
|
if err != nil {
|
2026-03-19 03:53:40 +01:00
|
|
|
slog.Error("failed to look up attachment", "id", fileID, "error", err)
|
2026-03-29 12:35:04 +02:00
|
|
|
writeJSON(w, http.StatusInternalServerError, errorResponse{
|
|
|
|
|
Error: "INTERNAL_ERROR",
|
|
|
|
|
Message: "internal server error",
|
|
|
|
|
})
|
2026-03-19 03:53:40 +01:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 11:16:16 +02:00
|
|
|
if aa == nil {
|
2026-03-18 14:13:52 +01:00
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:16:16 +02:00
|
|
|
// ── Access control ──────────────────────────────────────────────
|
|
|
|
|
isAdmin := role != nil && permissions.HasAdmin(role.Permissions)
|
|
|
|
|
|
|
|
|
|
if !isAdmin {
|
|
|
|
|
if aa.ChannelID == nil {
|
|
|
|
|
// Unlinked attachment — only the uploader may access.
|
2026-04-04 16:48:57 +00:00
|
|
|
// M-2: Legacy rows (NULL uploader_id) are now denied rather than
|
|
|
|
|
// served to any authenticated user.
|
2026-04-02 11:16:16 +02:00
|
|
|
if aa.UploaderID == nil {
|
2026-04-04 16:48:57 +00:00
|
|
|
slog.Warn("legacy attachment access denied (NULL uploader_id)", "id", fileID)
|
|
|
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
|
|
|
Error: "FORBIDDEN",
|
|
|
|
|
Message: "you do not have access to this file",
|
|
|
|
|
})
|
|
|
|
|
return
|
2026-04-02 11:16:16 +02:00
|
|
|
} else if user == nil || *aa.UploaderID != user.ID {
|
|
|
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
|
|
|
Error: "FORBIDDEN",
|
|
|
|
|
Message: "you do not have access to this file",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Linked attachment — check channel permissions.
|
|
|
|
|
if aa.ChannelType == "dm" {
|
|
|
|
|
if user == nil {
|
|
|
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
|
|
|
Error: "FORBIDDEN",
|
|
|
|
|
Message: "you do not have access to this file",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ok, dmErr := database.IsDMParticipant(user.ID, *aa.ChannelID)
|
|
|
|
|
if dmErr != nil || !ok {
|
|
|
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
|
|
|
Error: "FORBIDDEN",
|
|
|
|
|
Message: "you do not have access to this file",
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-05 21:31:35 +00:00
|
|
|
} else if user == nil || !permSvc.HasChannelPerm(user.ID, *aa.ChannelID, permissions.ReadMessages) {
|
2026-04-03 08:33:55 +02:00
|
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
|
|
|
Error: "FORBIDDEN",
|
|
|
|
|
Message: "you do not have access to this file",
|
|
|
|
|
})
|
|
|
|
|
return
|
2026-04-02 11:16:16 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:13:52 +01:00
|
|
|
// Open file from storage.
|
2026-04-02 11:16:16 +02:00
|
|
|
f, err := store.Open(aa.StoredAs)
|
2026-03-18 14:13:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer f.Close() //nolint:errcheck
|
|
|
|
|
|
|
|
|
|
// Set headers before ServeContent to ensure correct MIME type.
|
2026-04-02 11:16:16 +02:00
|
|
|
w.Header().Set("Content-Type", aa.MimeType)
|
2026-04-02 13:23:09 +02:00
|
|
|
// BUG-118: Force download for MIME types that could execute content
|
|
|
|
|
// under the OwnCord origin (HTML, SVG, XML, PDF).
|
|
|
|
|
disposition := "inline"
|
|
|
|
|
if isUnsafeInlineMIME(aa.MimeType) {
|
|
|
|
|
disposition = "attachment"
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": aa.Filename}))
|
2026-04-01 11:37:36 +02:00
|
|
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, immutable", fileCacheMaxAgeSeconds))
|
2026-03-29 12:35:04 +02:00
|
|
|
// CORS: allow webview to read the response body using configured origins.
|
|
|
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
|
|
|
for _, allowed := range allowedOrigins {
|
|
|
|
|
if allowed == "*" || strings.EqualFold(allowed, origin) {
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
|
|
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Type, Content-Length")
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-18 14:13:52 +01:00
|
|
|
|
2026-04-02 14:44:41 +02:00
|
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
|
|
2026-03-29 12:35:04 +02:00
|
|
|
// Use the actual file modification time so If-Modified-Since works correctly.
|
|
|
|
|
var modTime time.Time
|
|
|
|
|
if info, statErr := f.Stat(); statErr == nil {
|
|
|
|
|
modTime = info.ModTime()
|
|
|
|
|
}
|
2026-04-02 11:16:16 +02:00
|
|
|
http.ServeContent(w, r, aa.Filename, modTime, f)
|
2026-03-18 14:13:52 +01:00
|
|
|
}
|
|
|
|
|
}
|