refactor(code): Cleaned-up the code base
This commit is contained in:
@@ -1,608 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
const adminSessionCookie = "warpbox_admin_session"
|
||||
|
||||
type adminUserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Tags string
|
||||
CreatedAt string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
type adminTagRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Protected bool
|
||||
AdminAccess bool
|
||||
UploadAllowed bool
|
||||
ZipDownloadAllowed bool
|
||||
OneTimeDownloadAllowed bool
|
||||
RenewableAllowed bool
|
||||
MaxFileSizeBytes string
|
||||
MaxBoxSizeBytes string
|
||||
AllowedExpirySeconds string
|
||||
}
|
||||
|
||||
type adminBoxRow struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Expired bool
|
||||
OneTimeDownload bool
|
||||
PasswordProtected bool
|
||||
}
|
||||
|
||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(noStoreAdminHeaders)
|
||||
admin.GET("/login", app.handleAdminLogin)
|
||||
admin.POST("/login", app.handleAdminLoginPost)
|
||||
|
||||
protected := admin.Group("")
|
||||
protected.Use(app.requireAdminSession)
|
||||
protected.POST("/logout", app.handleAdminLogout)
|
||||
protected.GET("", app.handleAdminDashboard)
|
||||
protected.GET("/", app.handleAdminDashboard)
|
||||
protected.GET("/boxes", app.handleAdminBoxes)
|
||||
protected.GET("/users", app.handleAdminUsers)
|
||||
protected.POST("/users", app.handleAdminUsersPost)
|
||||
protected.GET("/tags", app.handleAdminTags)
|
||||
protected.POST("/tags", app.handleAdminTagsPost)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
protected.POST("/settings", app.handleAdminSettingsPost)
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||
if app.isAdminSessionValid(ctx) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||
return
|
||||
}
|
||||
app.renderAdminLogin(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled {
|
||||
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
user, ok, err := app.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||
return
|
||||
}
|
||||
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||
return
|
||||
}
|
||||
if !perms.AdminAccess {
|
||||
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
||||
_ = app.store.DeleteSession(token)
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err := boxstore.ListBoxSummaries()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
||||
return
|
||||
}
|
||||
|
||||
rows := make([]adminBoxRow, 0, len(summaries))
|
||||
totalSize := int64(0)
|
||||
expiredCount := 0
|
||||
for _, summary := range summaries {
|
||||
totalSize += summary.TotalSize
|
||||
if summary.Expired {
|
||||
expiredCount++
|
||||
}
|
||||
rows = append(rows, adminBoxRow{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||
Expired: summary.Expired,
|
||||
OneTimeDownload: summary.OneTimeDownload,
|
||||
PasswordProtected: summary.PasswordProtected,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"Boxes": rows,
|
||||
"TotalBoxes": len(rows),
|
||||
"TotalStorage": helpers.FormatBytes(totalSize),
|
||||
"ExpiredBoxes": expiredCount,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminUsers(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.PostForm("action") == "toggle_disabled" {
|
||||
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
app.renderAdminUsers(ctx, "User not found.")
|
||||
return
|
||||
}
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
||||
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
||||
return
|
||||
}
|
||||
}
|
||||
user.Disabled = !user.Disabled
|
||||
if err := app.store.UpdateUser(user); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
username := ctx.PostForm("username")
|
||||
email := ctx.PostForm("email")
|
||||
password := ctx.PostForm("password")
|
||||
tagIDs := ctx.PostFormArray("tag_ids")
|
||||
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||
users, err := app.store.ListUsers()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||
return
|
||||
}
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
return
|
||||
}
|
||||
tagNames := make(map[string]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagNames[tag.ID] = tag.Name
|
||||
}
|
||||
sort.Slice(users, func(i int, j int) bool {
|
||||
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
||||
})
|
||||
|
||||
currentID := ""
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok {
|
||||
currentID = currentUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]adminUserRow, 0, len(users))
|
||||
for _, user := range users {
|
||||
names := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if name := tagNames[tagID]; name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
rows = append(rows, adminUserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Tags: strings.Join(names, ", "),
|
||||
CreatedAt: formatAdminTime(user.CreatedAt),
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: user.ID == currentID,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Users": rows,
|
||||
"Tags": tags,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminTags(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminTags(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := parseTagPermissions(ctx)
|
||||
if err != nil {
|
||||
app.renderAdminTags(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
tag := metastore.Tag{
|
||||
Name: ctx.PostForm("name"),
|
||||
Description: ctx.PostForm("description"),
|
||||
Permissions: perms,
|
||||
}
|
||||
if err := app.store.CreateTag(&tag); err != nil {
|
||||
app.renderAdminTags(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
return
|
||||
}
|
||||
sort.Slice(tags, func(i int, j int) bool {
|
||||
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
|
||||
})
|
||||
rows := make([]adminTagRow, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
rows = append(rows, adminTagRow{
|
||||
ID: tag.ID,
|
||||
Name: tag.Name,
|
||||
Description: tag.Description,
|
||||
Protected: tag.Protected,
|
||||
AdminAccess: tag.Permissions.AdminAccess,
|
||||
UploadAllowed: tag.Permissions.UploadAllowed,
|
||||
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
|
||||
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
|
||||
RenewableAllowed: tag.Permissions.RenewableAllowed,
|
||||
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
|
||||
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
|
||||
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Tags": rows,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
value := ctx.PostForm(def.Key)
|
||||
if def.Type == config.SettingTypeBool {
|
||||
value = "false"
|
||||
if ctx.PostForm(def.Key) == "true" {
|
||||
value = "true"
|
||||
}
|
||||
}
|
||||
if err := app.config.ApplyOverride(def.Key, value); err != nil {
|
||||
app.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if err := app.store.SetSetting(def.Key, value); err != nil {
|
||||
app.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Rows": app.config.SettingRows(),
|
||||
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if !validAdminCSRF(ctx, session) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil || !perms.AdminAccess {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Set("adminUser", user)
|
||||
ctx.Set("adminPerms", perms)
|
||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
return false
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
return err == nil && perms.AdminAccess
|
||||
}
|
||||
|
||||
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
||||
tags, err := app.store.TagsByID(user.TagIDs)
|
||||
if err != nil {
|
||||
return metastore.EffectivePermissions{}, err
|
||||
}
|
||||
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
||||
}
|
||||
|
||||
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
||||
value, ok := ctx.Get("adminPerms")
|
||||
if !ok {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return false
|
||||
}
|
||||
perms, ok := value.(metastore.EffectivePermissions)
|
||||
if !ok || !allowed(perms) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user.Username
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
||||
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
||||
if token, ok := value.(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func noStoreAdminHeaders(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.Header("Pragma", "no-cache")
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
||||
switch ctx.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
|
||||
token := ctx.PostForm("csrf_token")
|
||||
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
||||
}
|
||||
|
||||
func subtleConstantTimeEqual(a string, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
||||
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
|
||||
}
|
||||
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
|
||||
}
|
||||
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, err
|
||||
}
|
||||
return metastore.TagPermissions{
|
||||
UploadAllowed: checkbox(ctx, "upload_allowed"),
|
||||
AllowedExpirySeconds: expirySeconds,
|
||||
MaxFileSizeBytes: maxFileSize,
|
||||
MaxBoxSizeBytes: maxBoxSize,
|
||||
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
|
||||
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
|
||||
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
|
||||
AdminAccess: checkbox(ctx, "admin_access"),
|
||||
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
|
||||
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
|
||||
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkbox(ctx *gin.Context, name string) bool {
|
||||
return ctx.PostForm(name) == "true"
|
||||
}
|
||||
|
||||
func parseOptionalInt64(raw string) (*int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("must be an integer")
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, errors.New("must be at least 0")
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func parseCSVInt64(raw string) ([]int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func optionalInt64Label(value *int64) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatInt(*value, 10)
|
||||
}
|
||||
|
||||
func joinInt64s(values []int64) string {
|
||||
if len(values) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, strconv.FormatInt(value, 10))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatAdminTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
192
lib/server/admin_auth.go
Normal file
192
lib/server/admin_auth.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
const adminSessionCookie = "warpbox_admin_session"
|
||||
|
||||
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||
if app.isAdminSessionValid(ctx) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||
return
|
||||
}
|
||||
app.renderAdminLogin(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled {
|
||||
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
user, ok, err := app.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||
return
|
||||
}
|
||||
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||
return
|
||||
}
|
||||
if !perms.AdminAccess {
|
||||
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
||||
return
|
||||
}
|
||||
|
||||
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||
return
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
||||
_ = app.store.DeleteSession(token)
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
}
|
||||
func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
if !validAdminCSRF(ctx, session) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil || !perms.AdminAccess {
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
ctx.Set("adminUser", user)
|
||||
ctx.Set("adminPerms", perms)
|
||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
||||
token, err := ctx.Cookie(adminSessionCookie)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
user, ok, err := app.store.GetUser(session.UserID)
|
||||
if err != nil || !ok || user.Disabled {
|
||||
return false
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
return err == nil && perms.AdminAccess
|
||||
}
|
||||
|
||||
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
||||
tags, err := app.store.TagsByID(user.TagIDs)
|
||||
if err != nil {
|
||||
return metastore.EffectivePermissions{}, err
|
||||
}
|
||||
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
||||
}
|
||||
|
||||
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
||||
value, ok := ctx.Get("adminPerms")
|
||||
if !ok {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return false
|
||||
}
|
||||
perms, ok := value.(metastore.EffectivePermissions)
|
||||
if !ok || !allowed(perms) {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user.Username
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) currentCSRFToken(ctx *gin.Context) string {
|
||||
if value, ok := ctx.Get("adminCSRFToken"); ok {
|
||||
if token, ok := value.(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func noStoreAdminHeaders(ctx *gin.Context) {
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.Header("Pragma", "no-cache")
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
|
||||
switch ctx.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
|
||||
token := ctx.PostForm("csrf_token")
|
||||
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
|
||||
}
|
||||
|
||||
func subtleConstantTimeEqual(a string, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
63
lib/server/admin_boxes.go
Normal file
63
lib/server/admin_boxes.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminBoxRow struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Expired bool
|
||||
OneTimeDownload bool
|
||||
PasswordProtected bool
|
||||
}
|
||||
|
||||
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
||||
return
|
||||
}
|
||||
|
||||
summaries, err := boxstore.ListBoxSummaries()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
||||
return
|
||||
}
|
||||
|
||||
rows := make([]adminBoxRow, 0, len(summaries))
|
||||
totalSize := int64(0)
|
||||
expiredCount := 0
|
||||
for _, summary := range summaries {
|
||||
totalSize += summary.TotalSize
|
||||
if summary.Expired {
|
||||
expiredCount++
|
||||
}
|
||||
rows = append(rows, adminBoxRow{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||
Expired: summary.Expired,
|
||||
OneTimeDownload: summary.OneTimeDownload,
|
||||
PasswordProtected: summary.PasswordProtected,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
|
||||
"AdminSection": "boxes",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"Boxes": rows,
|
||||
"TotalBoxes": len(rows),
|
||||
"TotalStorage": helpers.FormatBytes(totalSize),
|
||||
"ExpiredBoxes": expiredCount,
|
||||
})
|
||||
}
|
||||
14
lib/server/admin_dashboard.go
Normal file
14
lib/server/admin_dashboard.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
})
|
||||
}
|
||||
73
lib/server/admin_format.go
Normal file
73
lib/server/admin_format.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseOptionalInt64(raw string) (*int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("must be an integer")
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, errors.New("must be at least 0")
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func parseCSVInt64(raw string) ([]int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
|
||||
}
|
||||
if value < 0 {
|
||||
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func optionalInt64Label(value *int64) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatInt(*value, 10)
|
||||
}
|
||||
|
||||
func joinInt64s(values []int64) string {
|
||||
if len(values) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, strconv.FormatInt(value, 10))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func formatAdminTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
23
lib/server/admin_routes.go
Normal file
23
lib/server/admin_routes.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(noStoreAdminHeaders)
|
||||
admin.GET("/login", app.handleAdminLogin)
|
||||
admin.POST("/login", app.handleAdminLoginPost)
|
||||
|
||||
protected := admin.Group("")
|
||||
protected.Use(app.requireAdminSession)
|
||||
protected.POST("/logout", app.handleAdminLogout)
|
||||
protected.GET("", app.handleAdminDashboard)
|
||||
protected.GET("/", app.handleAdminDashboard)
|
||||
protected.GET("/boxes", app.handleAdminBoxes)
|
||||
protected.GET("/users", app.handleAdminUsers)
|
||||
protected.POST("/users", app.handleAdminUsersPost)
|
||||
protected.GET("/tags", app.handleAdminTags)
|
||||
protected.POST("/tags", app.handleAdminTagsPost)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
protected.POST("/settings", app.handleAdminSettingsPost)
|
||||
}
|
||||
58
lib/server/admin_settings.go
Normal file
58
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
value := ctx.PostForm(def.Key)
|
||||
if def.Type == config.SettingTypeBool {
|
||||
value = "false"
|
||||
if ctx.PostForm(def.Key) == "true" {
|
||||
value = "true"
|
||||
}
|
||||
}
|
||||
if err := app.config.ApplyOverride(def.Key, value); err != nil {
|
||||
app.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
if err := app.store.SetSetting(def.Key, value); err != nil {
|
||||
app.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
||||
"AdminSection": "settings",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Rows": app.config.SettingRows(),
|
||||
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
122
lib/server/admin_tags.go
Normal file
122
lib/server/admin_tags.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminTagRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
Protected bool
|
||||
AdminAccess bool
|
||||
UploadAllowed bool
|
||||
ZipDownloadAllowed bool
|
||||
OneTimeDownloadAllowed bool
|
||||
RenewableAllowed bool
|
||||
MaxFileSizeBytes string
|
||||
MaxBoxSizeBytes string
|
||||
AllowedExpirySeconds string
|
||||
}
|
||||
|
||||
func (app *App) handleAdminTags(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminTags(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := parseTagPermissions(ctx)
|
||||
if err != nil {
|
||||
app.renderAdminTags(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
tag := metastore.Tag{
|
||||
Name: ctx.PostForm("name"),
|
||||
Description: ctx.PostForm("description"),
|
||||
Permissions: perms,
|
||||
}
|
||||
if err := app.store.CreateTag(&tag); err != nil {
|
||||
app.renderAdminTags(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
return
|
||||
}
|
||||
sort.Slice(tags, func(i int, j int) bool {
|
||||
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
|
||||
})
|
||||
rows := make([]adminTagRow, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
rows = append(rows, adminTagRow{
|
||||
ID: tag.ID,
|
||||
Name: tag.Name,
|
||||
Description: tag.Description,
|
||||
Protected: tag.Protected,
|
||||
AdminAccess: tag.Permissions.AdminAccess,
|
||||
UploadAllowed: tag.Permissions.UploadAllowed,
|
||||
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
|
||||
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
|
||||
RenewableAllowed: tag.Permissions.RenewableAllowed,
|
||||
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
|
||||
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
|
||||
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
||||
"AdminSection": "tags",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Tags": rows,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
||||
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
|
||||
}
|
||||
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
|
||||
}
|
||||
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
|
||||
if err != nil {
|
||||
return metastore.TagPermissions{}, err
|
||||
}
|
||||
return metastore.TagPermissions{
|
||||
UploadAllowed: checkbox(ctx, "upload_allowed"),
|
||||
AllowedExpirySeconds: expirySeconds,
|
||||
MaxFileSizeBytes: maxFileSize,
|
||||
MaxBoxSizeBytes: maxBoxSize,
|
||||
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
|
||||
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
|
||||
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
|
||||
AdminAccess: checkbox(ctx, "admin_access"),
|
||||
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
|
||||
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
|
||||
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkbox(ctx *gin.Context, name string) bool {
|
||||
return ctx.PostForm(name) == "true"
|
||||
}
|
||||
121
lib/server/admin_users.go
Normal file
121
lib/server/admin_users.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type adminUserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Tags string
|
||||
CreatedAt string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminUsers(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.PostForm("action") == "toggle_disabled" {
|
||||
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
app.renderAdminUsers(ctx, "User not found.")
|
||||
return
|
||||
}
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
||||
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
||||
return
|
||||
}
|
||||
}
|
||||
user.Disabled = !user.Disabled
|
||||
if err := app.store.UpdateUser(user); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
return
|
||||
}
|
||||
|
||||
username := ctx.PostForm("username")
|
||||
email := ctx.PostForm("email")
|
||||
password := ctx.PostForm("password")
|
||||
tagIDs := ctx.PostFormArray("tag_ids")
|
||||
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
||||
app.renderAdminUsers(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||
}
|
||||
|
||||
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||
users, err := app.store.ListUsers()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||
return
|
||||
}
|
||||
tags, err := app.store.ListTags()
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||
return
|
||||
}
|
||||
tagNames := make(map[string]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagNames[tag.ID] = tag.Name
|
||||
}
|
||||
sort.Slice(users, func(i int, j int) bool {
|
||||
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
||||
})
|
||||
|
||||
currentID := ""
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if currentUser, ok := current.(metastore.User); ok {
|
||||
currentID = currentUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]adminUserRow, 0, len(users))
|
||||
for _, user := range users {
|
||||
names := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if name := tagNames[tagID]; name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
rows = append(rows, adminUserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Tags: strings.Join(names, ", "),
|
||||
CreatedAt: formatAdminTime(user.CreatedAt),
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: user.ID == currentID,
|
||||
})
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||
"AdminSection": "users",
|
||||
"CurrentUser": app.currentAdminUsername(ctx),
|
||||
"CSRFToken": app.currentCSRFToken(ctx),
|
||||
"Users": rows,
|
||||
"Tags": tags,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
135
lib/server/box_auth.go
Normal file
135
lib/server/box_auth.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
const boxAuthCookiePrefix = "warpbox_box_"
|
||||
|
||||
func handleBoxLogin(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
if boxstore.IsExpired(manifest) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
ctx.String(http.StatusGone, "Box expired")
|
||||
return
|
||||
}
|
||||
|
||||
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||
return
|
||||
}
|
||||
|
||||
renderBoxLogin(ctx, boxID, "")
|
||||
}
|
||||
|
||||
func handleBoxLoginPost(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
if boxstore.IsExpired(manifest) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
ctx.String(http.StatusGone, "Box expired")
|
||||
return
|
||||
}
|
||||
|
||||
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
|
||||
renderBoxLogin(ctx, boxID, "The password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := 24 * 60 * 60
|
||||
if !manifest.ExpiresAt.IsZero() {
|
||||
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
|
||||
if seconds > 0 {
|
||||
maxAge = seconds
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||
}
|
||||
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return models.BoxManifest{}, false, true
|
||||
}
|
||||
|
||||
if boxstore.IsExpired(manifest) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
if wantsHTML {
|
||||
ctx.String(http.StatusGone, "Box expired")
|
||||
} else {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if manifest.OneTimeDownload && manifest.Consumed {
|
||||
if wantsHTML {
|
||||
ctx.String(http.StatusGone, "Box already consumed")
|
||||
} else {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
|
||||
if wantsHTML {
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
|
||||
} else {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if app.config.RenewOnAccessEnabled {
|
||||
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
|
||||
manifest = renewed
|
||||
}
|
||||
}
|
||||
|
||||
return manifest, true, true
|
||||
}
|
||||
|
||||
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
|
||||
token, err := ctx.Cookie(boxAuthCookieName(boxID))
|
||||
return err == nil && boxstore.VerifyAuthToken(manifest, token)
|
||||
}
|
||||
|
||||
func boxAuthCookieName(boxID string) string {
|
||||
return boxAuthCookiePrefix + boxID
|
||||
}
|
||||
|
||||
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
|
||||
"BoxID": boxID,
|
||||
"BoxUser": "WarpBox\\" + boxID,
|
||||
"ErrorMessage": errorMessage,
|
||||
})
|
||||
}
|
||||
281
lib/server/downloads.go
Normal file
281
lib/server/downloads.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
var oneTimeDownloadLocks sync.Map
|
||||
|
||||
func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
if !app.config.ZipDownloadsEnabled {
|
||||
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
app.handleOneTimeDownloadBox(ctx, boxID)
|
||||
return
|
||||
}
|
||||
|
||||
if hasManifest && manifest.DisableZip {
|
||||
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
return
|
||||
}
|
||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
||||
lock := oneTimeDownloadLock(boxID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
defer oneTimeDownloadLocks.Delete(boxID)
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
|
||||
ctx.String(http.StatusGone, "Box already consumed")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if !allFilesComplete(files) {
|
||||
ctx.String(http.StatusConflict, "Box is not ready yet")
|
||||
return
|
||||
}
|
||||
|
||||
if app.config.OneTimeDownloadRetryOnFailure {
|
||||
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
|
||||
return
|
||||
}
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
|
||||
writeBoxZipHeaders(ctx, boxID)
|
||||
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
|
||||
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
|
||||
return
|
||||
}
|
||||
tempPath := tempZip.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
|
||||
tempZip.Close()
|
||||
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
|
||||
return
|
||||
}
|
||||
if _, err := tempZip.Seek(0, 0); err != nil {
|
||||
tempZip.Close()
|
||||
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
|
||||
return
|
||||
}
|
||||
|
||||
writeBoxZipHeaders(ctx, boxID)
|
||||
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
|
||||
tempZip.Close()
|
||||
return
|
||||
}
|
||||
if err := tempZip.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
|
||||
ctx.Header("Content-Type", "application/zip")
|
||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
||||
}
|
||||
|
||||
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
|
||||
zipWriter := zip.NewWriter(destination)
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsComplete {
|
||||
continue
|
||||
}
|
||||
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneTimeDownloadLock(boxID string) *sync.Mutex {
|
||||
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func allFilesComplete(files []models.BoxFile) bool {
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsComplete {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func manifestFilesReady(files []models.BoxFile) bool {
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.Status != models.FileStatusReady {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
|
||||
stripped := make([]models.BoxFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
file.ThumbnailPath = nil
|
||||
file.ThumbnailURL = ""
|
||||
if file.ThumbnailStatus == "" {
|
||||
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||
}
|
||||
stripped = append(stripped, file)
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
func (app *App) handleDownloadFile(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
||||
if !boxstore.ValidBoxID(boxID) || !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !authorized {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
|
||||
return
|
||||
}
|
||||
|
||||
path, ok := boxstore.SafeBoxFilePath(boxID, filename)
|
||||
if !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
ctx.String(http.StatusNotFound, "File not found")
|
||||
return
|
||||
}
|
||||
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.FileAttachment(path, filename)
|
||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !authorized {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
|
||||
return
|
||||
}
|
||||
|
||||
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
|
||||
if !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
ctx.String(http.StatusNotFound, "Thumbnail not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Header("Content-Type", "image/jpeg")
|
||||
ctx.File(path)
|
||||
}
|
||||
@@ -1,904 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
const boxAuthCookiePrefix = "warpbox_box_"
|
||||
|
||||
var oneTimeDownloadLocks sync.Map
|
||||
|
||||
func formatBrowserTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (app *App) handleIndex(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"RetentionOptions": app.retentionOptions(),
|
||||
"DefaultRetention": app.defaultRetentionOption().Key,
|
||||
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
||||
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
||||
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleShowBox(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
files = stripOneTimeThumbnailState(files)
|
||||
}
|
||||
|
||||
downloadAll := "/box/" + boxID + "/download"
|
||||
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
|
||||
downloadAll = ""
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "box.html", gin.H{
|
||||
"BoxID": boxID,
|
||||
"Files": files,
|
||||
"FileCount": len(files),
|
||||
"DownloadAll": downloadAll,
|
||||
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
||||
"PollMS": app.config.BoxPollIntervalMS,
|
||||
"RetentionLabel": manifest.RetentionLabel,
|
||||
"ExpiresAt": manifest.ExpiresAt,
|
||||
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
|
||||
})
|
||||
}
|
||||
|
||||
func handleBoxLogin(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
if boxstore.IsExpired(manifest) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
ctx.String(http.StatusGone, "Box expired")
|
||||
return
|
||||
}
|
||||
|
||||
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||
return
|
||||
}
|
||||
|
||||
renderBoxLogin(ctx, boxID, "")
|
||||
}
|
||||
|
||||
func handleBoxLoginPost(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
|
||||
if boxstore.IsExpired(manifest) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
ctx.String(http.StatusGone, "Box expired")
|
||||
return
|
||||
}
|
||||
|
||||
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
|
||||
renderBoxLogin(ctx, boxID, "The password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
maxAge := 24 * 60 * 60
|
||||
if !manifest.ExpiresAt.IsZero() {
|
||||
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
|
||||
if seconds > 0 {
|
||||
maxAge = seconds
|
||||
}
|
||||
}
|
||||
|
||||
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||
}
|
||||
|
||||
func (app *App) handleBoxStatus(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var files []models.BoxFile
|
||||
if hasManifest && manifestFilesReady(manifest.Files) {
|
||||
files = boxstore.DecorateFiles(boxID, manifest.Files)
|
||||
} else {
|
||||
var err error
|
||||
files, err = boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
files = stripOneTimeThumbnailState(files)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
|
||||
}
|
||||
|
||||
func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
if !app.config.ZipDownloadsEnabled {
|
||||
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
app.handleOneTimeDownloadBox(ctx, boxID)
|
||||
return
|
||||
}
|
||||
|
||||
if hasManifest && manifest.DisableZip {
|
||||
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
return
|
||||
}
|
||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
|
||||
lock := oneTimeDownloadLock(boxID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
defer oneTimeDownloadLocks.Delete(boxID)
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
|
||||
ctx.String(http.StatusGone, "Box already consumed")
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if !allFilesComplete(files) {
|
||||
ctx.String(http.StatusConflict, "Box is not ready yet")
|
||||
return
|
||||
}
|
||||
|
||||
if app.config.OneTimeDownloadRetryOnFailure {
|
||||
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
|
||||
return
|
||||
}
|
||||
if !app.writeBoxZip(ctx, boxID, files) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
|
||||
writeBoxZipHeaders(ctx, boxID)
|
||||
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
|
||||
ctx.Status(http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
|
||||
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
|
||||
return
|
||||
}
|
||||
tempPath := tempZip.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
|
||||
tempZip.Close()
|
||||
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
|
||||
return
|
||||
}
|
||||
if _, err := tempZip.Seek(0, 0); err != nil {
|
||||
tempZip.Close()
|
||||
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
|
||||
return
|
||||
}
|
||||
|
||||
writeBoxZipHeaders(ctx, boxID)
|
||||
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
|
||||
tempZip.Close()
|
||||
return
|
||||
}
|
||||
if err := tempZip.Close(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
manifest.Consumed = true
|
||||
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
|
||||
return
|
||||
}
|
||||
boxstore.DeleteBox(boxID)
|
||||
}
|
||||
|
||||
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
|
||||
ctx.Header("Content-Type", "application/zip")
|
||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
|
||||
}
|
||||
|
||||
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
|
||||
zipWriter := zip.NewWriter(destination)
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsComplete {
|
||||
continue
|
||||
}
|
||||
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneTimeDownloadLock(boxID string) *sync.Mutex {
|
||||
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
|
||||
return lock.(*sync.Mutex)
|
||||
}
|
||||
|
||||
func allFilesComplete(files []models.BoxFile) bool {
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsComplete {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func manifestFilesReady(files []models.BoxFile) bool {
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.Status != models.FileStatusReady {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
|
||||
stripped := make([]models.BoxFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
file.ThumbnailPath = nil
|
||||
file.ThumbnailURL = ""
|
||||
if file.ThumbnailStatus == "" {
|
||||
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
|
||||
}
|
||||
stripped = append(stripped, file)
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
func (app *App) handleDownloadFile(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
||||
if !boxstore.ValidBoxID(boxID) || !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !authorized {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
|
||||
return
|
||||
}
|
||||
|
||||
path, ok := boxstore.SafeBoxFilePath(boxID, filename)
|
||||
if !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
ctx.String(http.StatusNotFound, "File not found")
|
||||
return
|
||||
}
|
||||
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid file")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.FileAttachment(path, filename)
|
||||
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !authorized {
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
|
||||
return
|
||||
}
|
||||
|
||||
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
|
||||
if !ok {
|
||||
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
ctx.String(http.StatusNotFound, "Thumbnail not found")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Header("Content-Type", "image/jpeg")
|
||||
ctx.File(path)
|
||||
}
|
||||
|
||||
func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
var request models.CreateBoxRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||
return
|
||||
}
|
||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.CreateManifest(boxID, request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
||||
}
|
||||
|
||||
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||
if err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||
}
|
||||
|
||||
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
|
||||
return
|
||||
}
|
||||
|
||||
var request models.UpdateFileStatusRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
||||
return
|
||||
}
|
||||
if request.Status == models.FileStatusReady {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
|
||||
return
|
||||
}
|
||||
if err := app.rejectExpiredManifestBox(boxID); err != nil {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
||||
}
|
||||
|
||||
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||
}
|
||||
|
||||
func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
form, err := ctx.MultipartForm()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||
return
|
||||
}
|
||||
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||
return
|
||||
}
|
||||
totalSize := int64(0)
|
||||
for _, file := range files {
|
||||
if err := app.validateFileSize(file.Size); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
if err := app.validateBoxSize(totalSize); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
|
||||
if retentionKey == "" {
|
||||
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
|
||||
}
|
||||
allowZip := true
|
||||
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
|
||||
allowZip = false
|
||||
}
|
||||
request := models.CreateBoxRequest{
|
||||
RetentionKey: retentionKey,
|
||||
Password: ctx.PostForm("password"),
|
||||
AllowZip: &allowZip,
|
||||
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
|
||||
}
|
||||
for _, file := range files {
|
||||
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
|
||||
}
|
||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
manifestFiles, err := boxstore.CreateManifest(boxID, request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles := make([]models.BoxFile, 0, len(files))
|
||||
for index, file := range files {
|
||||
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
|
||||
if err != nil {
|
||||
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles = append(savedFiles, savedFile)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||
}
|
||||
|
||||
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return models.BoxManifest{}, false, true
|
||||
}
|
||||
|
||||
if boxstore.IsExpired(manifest) {
|
||||
boxstore.DeleteBox(boxID)
|
||||
if wantsHTML {
|
||||
ctx.String(http.StatusGone, "Box expired")
|
||||
} else {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if manifest.OneTimeDownload && manifest.Consumed {
|
||||
if wantsHTML {
|
||||
ctx.String(http.StatusGone, "Box already consumed")
|
||||
} else {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
|
||||
if wantsHTML {
|
||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
|
||||
} else {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
|
||||
}
|
||||
return manifest, true, false
|
||||
}
|
||||
|
||||
if app.config.RenewOnAccessEnabled {
|
||||
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
|
||||
manifest = renewed
|
||||
}
|
||||
}
|
||||
|
||||
return manifest, true, true
|
||||
}
|
||||
|
||||
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
|
||||
token, err := ctx.Cookie(boxAuthCookieName(boxID))
|
||||
return err == nil && boxstore.VerifyAuthToken(manifest, token)
|
||||
}
|
||||
|
||||
func boxAuthCookieName(boxID string) string {
|
||||
return boxAuthCookiePrefix + boxID
|
||||
}
|
||||
|
||||
func (app *App) requireAPI(ctx *gin.Context) bool {
|
||||
if app.config.APIEnabled {
|
||||
return true
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
|
||||
if app.config.GuestUploadsEnabled {
|
||||
return true
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
if !app.retentionAllowed(request.RetentionKey) {
|
||||
return fmt.Errorf("Retention option is not allowed")
|
||||
}
|
||||
if !app.config.ZipDownloadsEnabled {
|
||||
allowZip := false
|
||||
request.AllowZip = &allowZip
|
||||
}
|
||||
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||
return fmt.Errorf("One-time downloads are disabled")
|
||||
}
|
||||
|
||||
totalSize := int64(0)
|
||||
for _, file := range request.Files {
|
||||
if err := app.validateFileSize(file.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
}
|
||||
|
||||
func (app *App) validateIncomingFile(boxID string, size int64) error {
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return err
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
totalSize := size
|
||||
for _, file := range files {
|
||||
totalSize += file.Size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
}
|
||||
|
||||
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return app.validateIncomingFile(boxID, size)
|
||||
}
|
||||
if boxstore.IsExpired(manifest) {
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
totalSize := int64(0)
|
||||
found := false
|
||||
for _, file := range manifest.Files {
|
||||
if file.ID == fileID {
|
||||
totalSize += size
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
if !found {
|
||||
totalSize += size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
}
|
||||
|
||||
func (app *App) validateFileSize(size int64) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("File size cannot be negative")
|
||||
}
|
||||
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
|
||||
return fmt.Errorf("File exceeds the global max file size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) validateBoxSize(size int64) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("Box size cannot be negative")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
|
||||
return fmt.Errorf("Box exceeds the global max box size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) rejectExpiredManifestBox(boxID string) error {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !boxstore.IsExpired(manifest) {
|
||||
return nil
|
||||
}
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
|
||||
func (app *App) limitRequestBody(ctx *gin.Context) {
|
||||
limit := app.maxRequestBodyBytes()
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
|
||||
}
|
||||
|
||||
func (app *App) maxRequestBodyBytes() int64 {
|
||||
limit := app.config.GlobalMaxBoxSizeBytes
|
||||
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
|
||||
limit = app.config.GlobalMaxFileSizeBytes
|
||||
}
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
return limit + 10*1024*1024
|
||||
}
|
||||
|
||||
func (app *App) retentionAllowed(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return true
|
||||
}
|
||||
for _, option := range app.retentionOptions() {
|
||||
if option.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *App) retentionOptions() []models.RetentionOption {
|
||||
allOptions := boxstore.RetentionOptions()
|
||||
options := make([]models.RetentionOption, 0, len(allOptions))
|
||||
for _, option := range allOptions {
|
||||
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||
continue
|
||||
}
|
||||
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
|
||||
continue
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return allOptions[:1]
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (app *App) defaultRetentionOption() models.RetentionOption {
|
||||
options := app.retentionOptions()
|
||||
for _, option := range options {
|
||||
if option.Seconds == app.config.DefaultGuestExpirySeconds {
|
||||
return option
|
||||
}
|
||||
}
|
||||
return options[0]
|
||||
}
|
||||
|
||||
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
|
||||
"BoxID": boxID,
|
||||
"BoxUser": "WarpBox\\" + boxID,
|
||||
"ErrorMessage": errorMessage,
|
||||
})
|
||||
}
|
||||
100
lib/server/pages.go
Normal file
100
lib/server/pages.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func formatBrowserTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (app *App) handleIndex(ctx *gin.Context) {
|
||||
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
||||
"RetentionOptions": app.retentionOptions(),
|
||||
"DefaultRetention": app.defaultRetentionOption().Key,
|
||||
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
||||
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
||||
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleShowBox(ctx *gin.Context) {
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "Box not found")
|
||||
return
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
files = stripOneTimeThumbnailState(files)
|
||||
}
|
||||
|
||||
downloadAll := "/box/" + boxID + "/download"
|
||||
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
|
||||
downloadAll = ""
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "box.html", gin.H{
|
||||
"BoxID": boxID,
|
||||
"Files": files,
|
||||
"FileCount": len(files),
|
||||
"DownloadAll": downloadAll,
|
||||
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
||||
"PollMS": app.config.BoxPollIntervalMS,
|
||||
"RetentionLabel": manifest.RetentionLabel,
|
||||
"ExpiresAt": manifest.ExpiresAt,
|
||||
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
|
||||
})
|
||||
}
|
||||
func (app *App) handleBoxStatus(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var files []models.BoxFile
|
||||
if hasManifest && manifestFilesReady(manifest.Files) {
|
||||
files = boxstore.DecorateFiles(boxID, manifest.Files)
|
||||
} else {
|
||||
var err error
|
||||
files, err = boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasManifest && manifest.OneTimeDownload {
|
||||
files = stripOneTimeThumbnailState(files)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
|
||||
}
|
||||
49
lib/server/retention.go
Normal file
49
lib/server/retention.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func (app *App) retentionAllowed(key string) bool {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return true
|
||||
}
|
||||
for _, option := range app.retentionOptions() {
|
||||
if option.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *App) retentionOptions() []models.RetentionOption {
|
||||
allOptions := boxstore.RetentionOptions()
|
||||
options := make([]models.RetentionOption, 0, len(allOptions))
|
||||
for _, option := range allOptions {
|
||||
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||
continue
|
||||
}
|
||||
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
|
||||
continue
|
||||
}
|
||||
options = append(options, option)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
return allOptions[:1]
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (app *App) defaultRetentionOption() models.RetentionOption {
|
||||
options := app.retentionOptions()
|
||||
for _, option := range options {
|
||||
if option.Seconds == app.config.DefaultGuestExpirySeconds {
|
||||
return option
|
||||
}
|
||||
}
|
||||
return options[0]
|
||||
}
|
||||
236
lib/server/uploads.go
Normal file
236
lib/server/uploads.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
var request models.CreateBoxRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||
return
|
||||
}
|
||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.CreateManifest(boxID, request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
||||
}
|
||||
|
||||
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||
if err != nil {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||
}
|
||||
|
||||
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
fileID := ctx.Param("file_id")
|
||||
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
|
||||
return
|
||||
}
|
||||
|
||||
var request models.UpdateFileStatusRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
|
||||
return
|
||||
}
|
||||
if request.Status == models.FileStatusReady {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
|
||||
return
|
||||
}
|
||||
if err := app.rejectExpiredManifestBox(boxID); err != nil {
|
||||
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
||||
}
|
||||
|
||||
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
boxID := ctx.Param("id")
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||
return
|
||||
}
|
||||
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||
}
|
||||
|
||||
func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||
return
|
||||
}
|
||||
app.limitRequestBody(ctx)
|
||||
|
||||
form, err := ctx.MultipartForm()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||
return
|
||||
}
|
||||
|
||||
files := form.File["files"]
|
||||
if len(files) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||
return
|
||||
}
|
||||
totalSize := int64(0)
|
||||
for _, file := range files {
|
||||
if err := app.validateFileSize(file.Size); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
if err := app.validateBoxSize(totalSize); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
|
||||
return
|
||||
}
|
||||
|
||||
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
|
||||
if retentionKey == "" {
|
||||
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
|
||||
}
|
||||
allowZip := true
|
||||
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
|
||||
allowZip = false
|
||||
}
|
||||
request := models.CreateBoxRequest{
|
||||
RetentionKey: retentionKey,
|
||||
Password: ctx.PostForm("password"),
|
||||
AllowZip: &allowZip,
|
||||
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
|
||||
}
|
||||
for _, file := range files {
|
||||
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
|
||||
}
|
||||
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
manifestFiles, err := boxstore.CreateManifest(boxID, request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles := make([]models.BoxFile, 0, len(files))
|
||||
for index, file := range files {
|
||||
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
|
||||
if err != nil {
|
||||
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
savedFiles = append(savedFiles, savedFile)
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||
}
|
||||
155
lib/server/validation.go
Normal file
155
lib/server/validation.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func (app *App) requireAPI(ctx *gin.Context) bool {
|
||||
if app.config.APIEnabled {
|
||||
return true
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
|
||||
if app.config.GuestUploadsEnabled {
|
||||
return true
|
||||
}
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
if !app.retentionAllowed(request.RetentionKey) {
|
||||
return fmt.Errorf("Retention option is not allowed")
|
||||
}
|
||||
if !app.config.ZipDownloadsEnabled {
|
||||
allowZip := false
|
||||
request.AllowZip = &allowZip
|
||||
}
|
||||
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||
return fmt.Errorf("One-time downloads are disabled")
|
||||
}
|
||||
|
||||
totalSize := int64(0)
|
||||
for _, file := range request.Files {
|
||||
if err := app.validateFileSize(file.Size); err != nil {
|
||||
return err
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
}
|
||||
|
||||
func (app *App) validateIncomingFile(boxID string, size int64) error {
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return err
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
files, err := boxstore.ListFiles(boxID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
totalSize := size
|
||||
for _, file := range files {
|
||||
totalSize += file.Size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
}
|
||||
|
||||
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
|
||||
if err := app.validateFileSize(size); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return app.validateIncomingFile(boxID, size)
|
||||
}
|
||||
if boxstore.IsExpired(manifest) {
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
totalSize := int64(0)
|
||||
found := false
|
||||
for _, file := range manifest.Files {
|
||||
if file.ID == fileID {
|
||||
totalSize += size
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
totalSize += file.Size
|
||||
}
|
||||
if !found {
|
||||
totalSize += size
|
||||
}
|
||||
return app.validateBoxSize(totalSize)
|
||||
}
|
||||
|
||||
func (app *App) validateFileSize(size int64) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("File size cannot be negative")
|
||||
}
|
||||
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
|
||||
return fmt.Errorf("File exceeds the global max file size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) validateBoxSize(size int64) error {
|
||||
if size < 0 {
|
||||
return fmt.Errorf("Box size cannot be negative")
|
||||
}
|
||||
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
|
||||
return fmt.Errorf("Box exceeds the global max box size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) rejectExpiredManifestBox(boxID string) error {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !boxstore.IsExpired(manifest) {
|
||||
return nil
|
||||
}
|
||||
_ = boxstore.DeleteBox(boxID)
|
||||
return fmt.Errorf("Box expired")
|
||||
}
|
||||
|
||||
func (app *App) limitRequestBody(ctx *gin.Context) {
|
||||
limit := app.maxRequestBodyBytes()
|
||||
if limit <= 0 {
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
|
||||
}
|
||||
|
||||
func (app *App) maxRequestBodyBytes() int64 {
|
||||
limit := app.config.GlobalMaxBoxSizeBytes
|
||||
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
|
||||
limit = app.config.GlobalMaxFileSizeBytes
|
||||
}
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
return limit + 10*1024*1024
|
||||
}
|
||||
Reference in New Issue
Block a user