Modify the authentication handler to return an unauthorized error when an invalid or disabled bearer token is provided, rather than silently falling back to an anonymous request. This ensures that clients attempting to authenticate but failing (due to expired, malformed, or disabled tokens) are explicitly notified of the auth failure instead of proceeding anonymously. True anonymous requests without any Authorization header remain supported.
276 lines
9.3 KiB
Go
276 lines
9.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"warpbox.dev/backend/libs/helpers"
|
|
"warpbox.dev/backend/libs/jobs"
|
|
"warpbox.dev/backend/libs/services"
|
|
)
|
|
|
|
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
|
if authErr != nil {
|
|
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
|
return
|
|
}
|
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
|
settings, err := a.settingsService.UploadPolicy()
|
|
if err != nil {
|
|
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
|
|
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
|
|
return
|
|
}
|
|
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
|
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
|
return
|
|
}
|
|
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
|
rateKey := uploadRateKey(r, user, loggedIn)
|
|
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
|
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
|
return
|
|
}
|
|
|
|
if !isAdminUpload {
|
|
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize()))
|
|
}
|
|
parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())
|
|
if isAdminUpload {
|
|
parseLimit = 32 << 20
|
|
}
|
|
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
|
return
|
|
}
|
|
|
|
files := uploadFiles(r)
|
|
totalBytes := totalUploadBytes(files)
|
|
var ownerID string
|
|
var collectionID string
|
|
if loggedIn {
|
|
ownerID = user.ID
|
|
collectionID = r.FormValue("collection_id")
|
|
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
|
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
|
return
|
|
}
|
|
}
|
|
if !isAdminUpload {
|
|
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
|
helpers.WriteJSONError(w, status, message)
|
|
return
|
|
}
|
|
}
|
|
maxDays := parseInt(r.FormValue("max_days"))
|
|
if maxDays <= 0 {
|
|
maxDays = min(7, effectivePolicy.MaxDays)
|
|
}
|
|
if !isAdminUpload && maxDays > effectivePolicy.MaxDays {
|
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
|
return
|
|
}
|
|
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
|
MaxDays: maxDays,
|
|
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
|
Password: r.FormValue("password"),
|
|
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
|
OwnerID: ownerID,
|
|
CollectionID: collectionID,
|
|
SkipSizeLimit: isAdminUpload,
|
|
CreatorIP: uploadClientIP(r),
|
|
StorageBackendID: effectivePolicy.StorageBackendID,
|
|
})
|
|
if err != nil {
|
|
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if !isAdminUpload {
|
|
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
|
|
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
|
|
}
|
|
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
|
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4403, "error", err.Error())
|
|
}
|
|
}
|
|
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
|
|
|
if wantsJSON(r) {
|
|
helpers.WriteJSON(w, http.StatusCreated, result)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = fmt.Fprintln(w, result.BoxURL)
|
|
}
|
|
|
|
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
|
if len(files) == 0 {
|
|
return 0, ""
|
|
}
|
|
now := time.Now().UTC()
|
|
if policy.MaxUploadMB > 0 {
|
|
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
|
|
for _, file := range files {
|
|
if file.Size > maxBytes {
|
|
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
|
|
}
|
|
}
|
|
}
|
|
if !loggedIn {
|
|
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
|
}
|
|
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
|
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
|
|
}
|
|
if usage.UploadedBoxes+1 > policy.DailyBoxes {
|
|
return http.StatusTooManyRequests, "anonymous daily box limit reached"
|
|
}
|
|
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "active box limit could not be checked"
|
|
}
|
|
if activeBoxes+1 > policy.ActiveBoxes {
|
|
return http.StatusTooManyRequests, "anonymous active box limit reached"
|
|
}
|
|
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
|
return status, message
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
usage, err := a.settingsService.UsageForUser(user.ID, now)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
|
}
|
|
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
|
return http.StatusTooManyRequests, "daily upload limit reached"
|
|
}
|
|
if usage.UploadedBoxes+1 > policy.DailyBoxes {
|
|
return http.StatusTooManyRequests, "daily box limit reached"
|
|
}
|
|
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "active box limit could not be checked"
|
|
}
|
|
if activeBoxes+1 > policy.ActiveBoxes {
|
|
return http.StatusTooManyRequests, "active box limit reached"
|
|
}
|
|
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "storage quota could not be checked"
|
|
}
|
|
if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
|
|
return http.StatusRequestEntityTooLarge, "storage quota reached"
|
|
}
|
|
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
|
return status, message
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
|
|
now := time.Now().UTC()
|
|
if loggedIn {
|
|
return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
|
|
}
|
|
return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
|
|
}
|
|
|
|
func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
|
|
if loggedIn {
|
|
return a.settingsService.EffectivePolicyForUser(settings, user)
|
|
}
|
|
return a.settingsService.EffectivePolicyForAnonymous(settings)
|
|
}
|
|
|
|
func (a *App) checkStorageBackendCapacity(backendID string, settings services.UploadPolicySettings, totalBytes int64) (int, string) {
|
|
if backendID != services.StorageBackendLocal {
|
|
return 0, ""
|
|
}
|
|
backend, err := a.uploadService.Storage().Backend(services.StorageBackendLocal)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "storage backend could not be checked"
|
|
}
|
|
used, err := backend.Usage(context.Background())
|
|
if err != nil {
|
|
return http.StatusInternalServerError, "storage backend usage could not be checked"
|
|
}
|
|
if used+totalBytes > services.GigabytesToBytes(settings.LocalStorageMaxGB) {
|
|
return http.StatusRequestEntityTooLarge, "local storage limit reached"
|
|
}
|
|
return 0, ""
|
|
}
|
|
|
|
func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
|
|
if loggedIn && policy.MaxUploadMB <= 0 {
|
|
return fallback * 8
|
|
}
|
|
if policy.MaxUploadMB > 0 {
|
|
return services.MegabytesToBytes(policy.MaxUploadMB) * 8
|
|
}
|
|
return fallback * 8
|
|
}
|
|
|
|
func uploadClientIP(r *http.Request) string {
|
|
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
|
}
|
|
|
|
func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
|
|
if loggedIn {
|
|
return "user:" + user.ID
|
|
}
|
|
return "ip:" + uploadClientIP(r)
|
|
}
|
|
|
|
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
|
var total int64
|
|
for _, file := range files {
|
|
total += file.Size
|
|
}
|
|
return total
|
|
}
|
|
|
|
func parseInt(value string) int {
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func statusForDownloadError(err error) int {
|
|
if errors.Is(err, http.ErrMissingFile) {
|
|
return http.StatusNotFound
|
|
}
|
|
return http.StatusForbidden
|
|
}
|
|
|
|
func uploadFiles(r *http.Request) []*multipart.FileHeader {
|
|
if r.MultipartForm == nil {
|
|
return nil
|
|
}
|
|
files := make([]*multipart.FileHeader, 0)
|
|
files = append(files, r.MultipartForm.File["file"]...)
|
|
files = append(files, r.MultipartForm.File["sharex"]...)
|
|
return files
|
|
}
|
|
|
|
func wantsJSON(r *http.Request) bool {
|
|
return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json")
|
|
}
|