Compare commits
2 Commits
v0.0.1-tes
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d77f164900 | |||
| 9a3cb90b17 |
@@ -10,6 +10,12 @@ WARPBOX_CLEANUP_EVERY=1h
|
|||||||
WARPBOX_THUMBNAIL_ENABLED=true
|
WARPBOX_THUMBNAIL_ENABLED=true
|
||||||
WARPBOX_THUMBNAIL_EVERY=1m
|
WARPBOX_THUMBNAIL_EVERY=1m
|
||||||
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
||||||
|
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
|
||||||
|
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
|
||||||
|
WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
|
||||||
|
WARPBOX_USER_DAILY_UPLOAD_MB=8192
|
||||||
|
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
|
||||||
|
WARPBOX_USAGE_RETENTION_DAYS=30
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_WRITE_TIMEOUT=60s
|
||||||
WARPBOX_IDLE_TIMEOUT=120s
|
WARPBOX_IDLE_TIMEOUT=120s
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -13,6 +13,16 @@ The default server listens on `:8080`.
|
|||||||
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
|
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
|
||||||
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
|
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
|
||||||
|
|
||||||
|
Upload policy defaults are also configured in megabytes and can later be changed from
|
||||||
|
`/admin/settings`:
|
||||||
|
|
||||||
|
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
|
||||||
|
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
|
||||||
|
- `WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048`
|
||||||
|
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
|
||||||
|
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
|
||||||
|
- `WARPBOX_USAGE_RETENTION_DAYS=30`
|
||||||
|
|
||||||
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
|
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
|
||||||
The dev script resolves that path from the repository root.
|
The dev script resolves that path from the repository root.
|
||||||
|
|
||||||
@@ -20,7 +30,12 @@ Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs ca
|
|||||||
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
||||||
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
||||||
|
|
||||||
The basic admin console is available at `/admin`. Set `WARPBOX_ADMIN_TOKEN` and use that value to sign in.
|
On a fresh data directory, visit `/register` to create the first account. That first user becomes
|
||||||
|
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
|
||||||
|
links from `/admin/users`.
|
||||||
|
|
||||||
|
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
|
||||||
|
at `/admin/login` if you need to recover access without a user session.
|
||||||
|
|
||||||
For one-off Go commands, run them from the backend module:
|
For one-off Go commands, run them from the backend module:
|
||||||
|
|
||||||
@@ -97,6 +112,26 @@ curl -F sharex=@./screenshot.png \
|
|||||||
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
||||||
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
||||||
|
|
||||||
|
## Stage 4 Accounts + Personal Boxes
|
||||||
|
|
||||||
|
- `/register` bootstraps the first admin account only when no users exist.
|
||||||
|
- `/login` and `/logout` provide cookie-based web sessions.
|
||||||
|
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
||||||
|
history, and flat collections. Uploading still happens from the homepage.
|
||||||
|
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
|
||||||
|
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is
|
||||||
|
stored with owner and optional collection metadata.
|
||||||
|
- Admin users are exempt from the global max upload size on the homepage upload flow. Future
|
||||||
|
per-user quotas should apply to this same upload path rather than creating a second uploader.
|
||||||
|
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
||||||
|
user storage quota, and usage retention.
|
||||||
|
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
||||||
|
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
|
||||||
|
tokens, thumbnails, and cleanup continue to work as before.
|
||||||
|
|
||||||
|
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
||||||
|
support will power public forgot-password and optional email delivery.
|
||||||
|
|
||||||
## Runtime Data
|
## Runtime Data
|
||||||
|
|
||||||
Warpbox keeps local runtime data under the configured data directory:
|
Warpbox keeps local runtime data under the configured data directory:
|
||||||
@@ -104,6 +139,9 @@ Warpbox keeps local runtime data under the configured data directory:
|
|||||||
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents.
|
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents.
|
||||||
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available.
|
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available.
|
||||||
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
|
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
|
||||||
|
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
|
||||||
|
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
|
||||||
|
for anonymous uploads and user ID for signed-in uploads.
|
||||||
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
||||||
|
|
||||||
## Static Asset Policy
|
## Static Asset Policy
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
|
golang.org/x/crypto v0.33.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.29.0 // indirect
|
require golang.org/x/sys v0.30.0 // indirect
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ type Config struct {
|
|||||||
ThumbnailEnabled bool
|
ThumbnailEnabled bool
|
||||||
ThumbnailEvery time.Duration
|
ThumbnailEvery time.Duration
|
||||||
MaxUploadSize int64
|
MaxUploadSize int64
|
||||||
|
DefaultSettings SettingsDefaults
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsDefaults struct {
|
||||||
|
AnonymousUploadsEnabled bool
|
||||||
|
AnonymousMaxUploadMB float64
|
||||||
|
AnonymousDailyUploadMB float64
|
||||||
|
UserDailyUploadMB float64
|
||||||
|
DefaultUserStorageMB float64
|
||||||
|
UsageRetentionDays int
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -49,6 +59,14 @@ func Load() (Config, error) {
|
|||||||
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
||||||
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||||
|
DefaultSettings: SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||||
|
AnonymousMaxUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
||||||
|
AnonymousDailyUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048),
|
||||||
|
UserDailyUploadMB: envMegabytesFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
|
||||||
|
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
|
||||||
|
UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.BaseURL == "" {
|
if cfg.BaseURL == "" {
|
||||||
@@ -57,6 +75,13 @@ func Load() (Config, error) {
|
|||||||
if cfg.MaxUploadSize <= 0 {
|
if cfg.MaxUploadSize <= 0 {
|
||||||
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
||||||
}
|
}
|
||||||
|
if cfg.DefaultSettings.AnonymousMaxUploadMB <= 0 ||
|
||||||
|
cfg.DefaultSettings.AnonymousDailyUploadMB <= 0 ||
|
||||||
|
cfg.DefaultSettings.UserDailyUploadMB <= 0 ||
|
||||||
|
cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
|
||||||
|
cfg.DefaultSettings.UsageRetentionDays <= 0 {
|
||||||
|
return Config{}, fmt.Errorf("upload policy settings must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -109,6 +134,19 @@ func envBool(key string, fallback bool) bool {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) int {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func envMegabytes(key string, fallback float64) int64 {
|
func envMegabytes(key string, fallback float64) int64 {
|
||||||
value := strings.TrimSpace(os.Getenv(key))
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -122,7 +160,27 @@ func envMegabytes(key string, fallback float64) int64 {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envMegabytesFloat(key string, fallback float64) float64 {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := parseMegabytesFloat(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func parseMegabytes(value string) (int64, error) {
|
func parseMegabytes(value string) (int64, error) {
|
||||||
|
sizeMB, err := parseMegabytesFloat(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return megabytesToBytes(sizeMB), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMegabytesFloat(value string) (float64, error) {
|
||||||
normalized := strings.TrimSpace(value)
|
normalized := strings.TrimSpace(value)
|
||||||
normalized = strings.TrimSuffix(normalized, "MB")
|
normalized = strings.TrimSuffix(normalized, "MB")
|
||||||
normalized = strings.TrimSuffix(normalized, "Mb")
|
normalized = strings.TrimSuffix(normalized, "Mb")
|
||||||
@@ -137,7 +195,7 @@ func parseMegabytes(value string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("megabyte value must be positive")
|
return 0, fmt.Errorf("megabyte value must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
return megabytesToBytes(sizeMB), nil
|
return sizeMB, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func megabytesToBytes(sizeMB float64) int64 {
|
func megabytesToBytes(sizeMB float64) int64 {
|
||||||
|
|||||||
444
backend/libs/handlers/accounts_test.go
Normal file
444
backend/libs/handlers/accounts_test.go
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login("daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("owned upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var ownedPayload services.UploadResult
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &ownedPayload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal owned returned error: %v", err)
|
||||||
|
}
|
||||||
|
ownedBox, err := app.uploadService.GetBox(ownedPayload.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox owned returned error: %v", err)
|
||||||
|
}
|
||||||
|
if ownedBox.OwnerID != user.ID {
|
||||||
|
t.Fatalf("owned OwnerID = %q, want %q", ownedBox.OwnerID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
owned := uploadThroughApp(t, app)
|
||||||
|
anonymous, err := app.uploadService.GetBox(owned.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox anonymous returned error: %v", err)
|
||||||
|
}
|
||||||
|
if anonymous.OwnerID != "" {
|
||||||
|
t.Fatalf("anonymous OwnerID = %q, want empty", anonymous.OwnerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
boxes, err := app.uploadService.ListBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListBoxes returned error: %v", err)
|
||||||
|
}
|
||||||
|
foundOwned := false
|
||||||
|
for _, box := range boxes {
|
||||||
|
if box.OwnerID == user.ID {
|
||||||
|
foundOwned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundOwned {
|
||||||
|
t.Fatalf("logged-in upload did not store owner id %q", user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := app.authService.CreateInvite("friend@example.test", services.UserRoleUser, admin.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/invite/"+invite.Token, strings.NewReader("username=friend&password=password123"))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.SetPathValue("token", invite.Token)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.InvitePost(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("InvitePost status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := app.authService.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
|
||||||
|
t.Fatalf("invite token remained reusable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonOwnerCannotManageOwnedBox(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
owner, err := app.authService.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := app.authService.CreateInvite("other@example.test", services.UserRoleUser, owner.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
other, err := app.authService.AcceptInvite(invite.Token, "other", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := createOwnedBoxThroughApp(t, app, owner.ID)
|
||||||
|
if err := app.uploadService.RenameOwnedBox(result.BoxID, other.ID, "stolen"); err == nil {
|
||||||
|
t.Fatalf("RenameOwnedBox allowed non-owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("admin upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnonymousUploadDisabled(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.AnonymousUploadsEnabled = false
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnonymousUploadLimits(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.AnonymousMaxUploadMB = 1
|
||||||
|
policy.AnonymousDailyUploadMB = 0.001
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
large := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
|
||||||
|
large.Header.Set("Accept", "application/json")
|
||||||
|
large.RemoteAddr = "192.0.2.10:1234"
|
||||||
|
largeResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(largeResponse, large)
|
||||||
|
if largeResponse.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("large status = %d, body = %s", largeResponse.Code, largeResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", strings.Repeat("x", 2048))
|
||||||
|
daily.Header.Set("Accept", "application/json")
|
||||||
|
daily.RemoteAddr = "192.0.2.10:1234"
|
||||||
|
dailyResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(dailyResponse, daily)
|
||||||
|
if dailyResponse.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("daily status = %d, body = %s", dailyResponse.Code, dailyResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignedInUploadQuotaAndOverride(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
user, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, user.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
normal, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login(normal.Email, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.DefaultUserStorageMB = 0.001
|
||||||
|
policy.UserDailyUploadMB = 8
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("quota status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
override := 10.0
|
||||||
|
if err := app.authService.SetUserStorageQuota(normal.ID, &override); err != nil {
|
||||||
|
t.Fatalf("SetUserStorageQuota returned error: %v", err)
|
||||||
|
}
|
||||||
|
request = multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response = httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("override status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignedInDailyCap(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login(user.Email, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.UserDailyUploadMB = 0.001
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "daily.txt", strings.Repeat("x", 2048))
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("daily status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30")
|
||||||
|
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
|
||||||
|
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
settingsResponse := httptest.NewRecorder()
|
||||||
|
app.AdminSettingsPost(settingsResponse, settingsRequest)
|
||||||
|
if settingsResponse.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("AdminSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
|
||||||
|
uploadRequest.Header.Set("Accept", "application/json")
|
||||||
|
uploadResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(uploadResponse, uploadRequest)
|
||||||
|
if uploadResponse.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("upload status = %d, want 403, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUserQuotaPostChangesEnforcement(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, adminToken, err := app.authService.Login(admin.Email, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001"))
|
||||||
|
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
quotaRequest.SetPathValue("userID", user.ID)
|
||||||
|
quotaResponse := httptest.NewRecorder()
|
||||||
|
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)
|
||||||
|
if quotaResponse.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("AdminUpdateUserQuota status = %d, body = %s", quotaResponse.Code, quotaResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, userToken, err := app.authService.Login(user.Email, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
|
||||||
|
uploadRequest.Header.Set("Accept", "application/json")
|
||||||
|
uploadRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: userToken})
|
||||||
|
uploadResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(uploadResponse, uploadRequest)
|
||||||
|
if uploadResponse.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("upload status = %d, want 413, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHomeReflectsUploadPolicySettings(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.AnonymousMaxUploadMB = 123
|
||||||
|
policy.AnonymousDailyUploadMB = 456
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Home(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Home status = %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
|
||||||
|
t.Fatalf("home did not reflect policy settings: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.APIDocs(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("APIDocs status = %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
header := body[:strings.Index(body, "<main")]
|
||||||
|
if !strings.Contains(header, "My Account") || strings.Contains(header, ">Login<") || strings.Contains(header, "Health") {
|
||||||
|
t.Fatalf("api header did not reflect logged-in state: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/api", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.APIDocs(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("APIDocs status = %d", response.Code)
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
header := body[:strings.Index(body, "<main")]
|
||||||
|
if !strings.Contains(header, ">Login<") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "My Account") {
|
||||||
|
t.Fatalf("api header did not reflect logged-out state: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||||
|
t.Helper()
|
||||||
|
user, err := app.authService.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByID returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login(user.Email, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
|
||||||
|
t.Helper()
|
||||||
|
policy, err := app.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -13,13 +15,19 @@ import (
|
|||||||
const adminCookieName = "warpbox_admin"
|
const adminCookieName = "warpbox_admin"
|
||||||
|
|
||||||
type adminPageData struct {
|
type adminPageData struct {
|
||||||
Stats services.AdminStats
|
Stats services.AdminStats
|
||||||
Boxes []adminBoxView
|
Boxes []adminBoxView
|
||||||
Error string
|
Users []adminUserView
|
||||||
|
Settings services.UploadPolicySettings
|
||||||
|
Section string
|
||||||
|
PageTitle string
|
||||||
|
LastInviteURL string
|
||||||
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
type adminBoxView struct {
|
type adminBoxView struct {
|
||||||
ID string
|
ID string
|
||||||
|
Owner string
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
ExpiresAt string
|
ExpiresAt string
|
||||||
FileCount int
|
FileCount int
|
||||||
@@ -30,22 +38,34 @@ type adminBoxView struct {
|
|||||||
Expired bool
|
Expired bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminUserView struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Status string
|
||||||
|
StorageUsed string
|
||||||
|
StorageQuota string
|
||||||
|
DailyUsed string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
if a.isAdmin(r) {
|
if a.isAdmin(r) {
|
||||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.renderAdminLogin(w, http.StatusOK, "")
|
a.renderAdminLogin(w, r, http.StatusOK, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
a.renderAdminLogin(w, http.StatusBadRequest, "Unable to read login form.")
|
a.renderAdminLogin(w, r, http.StatusBadRequest, "Unable to read login form.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
|
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
|
||||||
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
|
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
|
||||||
a.renderAdminLogin(w, http.StatusUnauthorized, "Invalid admin token.")
|
a.renderAdminLogin(w, r, http.StatusUnauthorized, "Invalid admin token.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +83,7 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.clearUserSessionCookie(w)
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: adminCookieName,
|
Name: adminCookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
@@ -90,12 +111,15 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
||||||
Title: "Admin overview",
|
Title: "Admin overview",
|
||||||
Description: "Warpbox admin overview.",
|
Description: "Warpbox admin overview.",
|
||||||
|
CurrentUser: a.currentPublicUser(r),
|
||||||
Data: adminPageData{
|
Data: adminPageData{
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
Boxes: boxes,
|
Boxes: boxes,
|
||||||
|
Section: "overview",
|
||||||
|
PageTitle: "Admin overview",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,16 +140,200 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
||||||
Title: "Admin files",
|
Title: "Admin files",
|
||||||
Description: "Manage Warpbox uploads.",
|
Description: "Manage Warpbox uploads.",
|
||||||
|
CurrentUser: a.currentPublicUser(r),
|
||||||
Data: adminPageData{
|
Data: adminPageData{
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
Boxes: boxes,
|
Boxes: boxes,
|
||||||
|
Section: "files",
|
||||||
|
PageTitle: "Admin files",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats, err := a.uploadService.AdminStats()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := a.authService.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load users", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := make([]adminUserView, 0, len(users))
|
||||||
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load settings", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID)
|
||||||
|
usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
|
||||||
|
quotaMB := settings.DefaultUserStorageMB
|
||||||
|
if user.StorageQuotaMB != nil {
|
||||||
|
quotaMB = *user.StorageQuotaMB
|
||||||
|
}
|
||||||
|
rows = append(rows, adminUserView{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Role: user.Role,
|
||||||
|
Status: user.Status,
|
||||||
|
StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
|
||||||
|
StorageQuota: formatMB(quotaMB),
|
||||||
|
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
|
||||||
|
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{
|
||||||
|
Title: "Admin users",
|
||||||
|
Description: "Manage Warpbox users and invites.",
|
||||||
|
CurrentUser: a.currentPublicUser(r),
|
||||||
|
Data: adminPageData{
|
||||||
|
Stats: stats,
|
||||||
|
Users: rows,
|
||||||
|
Section: "users",
|
||||||
|
PageTitle: "Users",
|
||||||
|
LastInviteURL: r.URL.Query().Get("invite"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load settings", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{
|
||||||
|
Title: "Admin settings",
|
||||||
|
Description: "Manage Warpbox upload policy.",
|
||||||
|
CurrentUser: a.currentPublicUser(r),
|
||||||
|
Data: adminPageData{
|
||||||
|
Settings: settings,
|
||||||
|
Section: "settings",
|
||||||
|
PageTitle: "Settings",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings := services.UploadPolicySettings{
|
||||||
|
AnonymousUploadsEnabled: r.FormValue("anonymous_uploads_enabled") == "on",
|
||||||
|
UsageRetentionDays: parsePositiveInt(r.FormValue("usage_retention_days")),
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_daily_upload_mb")); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if settings.UserDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("user_daily_upload_mb")); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if settings.DefaultUserStorageMB, err = services.ParseMegabytesValue(r.FormValue("default_user_storage_mb")); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if settings.UsageRetentionDays <= 0 {
|
||||||
|
http.Error(w, "usage retention days must be positive", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.settingsService.UpdateUploadPolicy(settings); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var quota *float64
|
||||||
|
if r.FormValue("storage_quota_mb") != "" {
|
||||||
|
parsed, err := services.ParseMegabytesValue(r.FormValue("storage_quota_mb"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quota = &parsed
|
||||||
|
}
|
||||||
|
if err := a.authService.SetUserStorageQuota(r.PathValue("userID"), quota); err != nil {
|
||||||
|
http.Error(w, "unable to update quota", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := a.requireAdminUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := a.authService.CreateInvite(r.FormValue("email"), r.FormValue("role"), admin.ID, 7*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID)
|
||||||
|
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disabled := r.URL.Query().Get("disabled") != "false"
|
||||||
|
if err := a.authService.DisableUser(r.PathValue("userID"), disabled); err != nil {
|
||||||
|
http.Error(w, "unable to update user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
admin, ok := a.requireAdminUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to create reset link", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.requireAdmin(w, r) {
|
if !a.requireAdmin(w, r) {
|
||||||
return
|
return
|
||||||
@@ -167,8 +375,8 @@ func (a *App) AdminViewBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther)
|
http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) {
|
func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||||
a.renderer.Render(w, status, "admin_login.html", web.PageData{
|
a.renderPage(w, r, status, "admin_login.html", web.PageData{
|
||||||
Title: "Admin login",
|
Title: "Admin login",
|
||||||
Description: "Sign in to the Warpbox admin console.",
|
Description: "Sign in to the Warpbox admin console.",
|
||||||
Data: adminPageData{
|
Data: adminPageData{
|
||||||
@@ -185,8 +393,17 @@ func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
|
|||||||
|
|
||||||
rows := make([]adminBoxView, 0, len(boxes))
|
rows := make([]adminBoxView, 0, len(boxes))
|
||||||
for _, box := range boxes {
|
for _, box := range boxes {
|
||||||
|
owner := "Anonymous"
|
||||||
|
if box.OwnerID != "" {
|
||||||
|
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
|
||||||
|
owner = user.Email
|
||||||
|
} else {
|
||||||
|
owner = "User"
|
||||||
|
}
|
||||||
|
}
|
||||||
rows = append(rows, adminBoxView{
|
rows = append(rows, adminBoxView{
|
||||||
ID: box.ID,
|
ID: box.ID,
|
||||||
|
Owner: owner,
|
||||||
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
|
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
|
||||||
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
|
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
|
||||||
FileCount: box.FileCount,
|
FileCount: box.FileCount,
|
||||||
@@ -209,6 +426,9 @@ func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) isAdmin(r *http.Request) bool {
|
func (a *App) isAdmin(r *http.Request) bool {
|
||||||
|
if user, ok := a.currentUser(r); ok && user.Role == services.UserRoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if a.cfg.AdminToken == "" {
|
if a.cfg.AdminToken == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -219,7 +439,38 @@ func (a *App) isAdmin(r *http.Request) bool {
|
|||||||
return cookie.Value == adminCookieValue(a.cfg.AdminToken)
|
return cookie.Value == adminCookieValue(a.cfg.AdminToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) requireAdminUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
||||||
|
user, ok := a.currentUser(r)
|
||||||
|
if ok && user.Role == services.UserRoleAdmin {
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
if a.cfg.AdminToken != "" && a.isAdmin(r) {
|
||||||
|
return services.User{ID: "env-admin", Role: services.UserRoleAdmin, Status: services.UserStatusActive}, true
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||||
|
return services.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) currentPublicUser(r *http.Request) any {
|
||||||
|
if user, ok := a.currentUser(r); ok {
|
||||||
|
return a.authService.PublicUser(user)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func adminCookieValue(token string) string {
|
func adminCookieValue(token string) string {
|
||||||
sum := sha256.Sum256([]byte("warpbox-admin:" + token))
|
sum := sha256.Sum256([]byte("warpbox-admin:" + token))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt(value string) int {
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMB(value float64) string {
|
||||||
|
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type apiDocsData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||||
a.renderer.Render(w, http.StatusOK, "api.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
|
||||||
Title: "API documentation",
|
Title: "API documentation",
|
||||||
Description: "Curl and ShareX upload examples for Warpbox.",
|
Description: "Curl and ShareX upload examples for Warpbox.",
|
||||||
Data: apiDocsData{
|
Data: apiDocsData{
|
||||||
|
|||||||
@@ -10,29 +10,61 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
renderer *web.Renderer
|
renderer *web.Renderer
|
||||||
uploadService *services.UploadService
|
uploadService *services.UploadService
|
||||||
|
authService *services.AuthService
|
||||||
|
settingsService *services.SettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *App {
|
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
|
||||||
return &App{
|
return &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
|
authService: authService,
|
||||||
|
settingsService: settingsService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, page string, data web.PageData) {
|
||||||
|
if data.CurrentUser == nil {
|
||||||
|
data.CurrentUser = a.currentPublicUser(r)
|
||||||
|
}
|
||||||
|
a.renderer.Render(w, status, page, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /", a.Home)
|
mux.HandleFunc("GET /", a.Home)
|
||||||
mux.HandleFunc("GET /api", a.APIDocs)
|
mux.HandleFunc("GET /api", a.APIDocs)
|
||||||
|
mux.HandleFunc("GET /register", a.Register)
|
||||||
|
mux.HandleFunc("POST /register", a.RegisterPost)
|
||||||
|
mux.HandleFunc("GET /login", a.Login)
|
||||||
|
mux.HandleFunc("POST /login", a.LoginPost)
|
||||||
|
mux.HandleFunc("POST /logout", a.Logout)
|
||||||
|
mux.HandleFunc("GET /invite/{token}", a.Invite)
|
||||||
|
mux.HandleFunc("POST /invite/{token}", a.InvitePost)
|
||||||
|
mux.HandleFunc("GET /app", a.Dashboard)
|
||||||
|
mux.HandleFunc("POST /app/collections", a.CreateCollection)
|
||||||
|
mux.HandleFunc("POST /app/boxes/{boxID}/rename", a.RenameUserBox)
|
||||||
|
mux.HandleFunc("POST /app/boxes/{boxID}/move", a.MoveUserBox)
|
||||||
|
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
||||||
|
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
||||||
|
mux.HandleFunc("POST /account/password", a.ChangePassword)
|
||||||
mux.HandleFunc("GET /admin/login", a.AdminLogin)
|
mux.HandleFunc("GET /admin/login", a.AdminLogin)
|
||||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||||
mux.HandleFunc("GET /admin", a.AdminDashboard)
|
mux.HandleFunc("GET /admin", a.AdminDashboard)
|
||||||
mux.HandleFunc("GET /admin/files", a.AdminFiles)
|
mux.HandleFunc("GET /admin/files", a.AdminFiles)
|
||||||
|
mux.HandleFunc("GET /admin/users", a.AdminUsers)
|
||||||
|
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
|
||||||
|
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
|
||||||
|
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
|
||||||
|
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
|
||||||
|
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
|
||||||
|
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
|
||||||
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
|
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
|
||||||
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
|
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
|
||||||
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
||||||
|
|||||||
211
backend/libs/handlers/auth.go
Normal file
211
backend/libs/handlers/auth.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
"warpbox.dev/backend/libs/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const userSessionCookieName = "warpbox_session"
|
||||||
|
|
||||||
|
type authPageData struct {
|
||||||
|
Mode string
|
||||||
|
Token string
|
||||||
|
Email string
|
||||||
|
IsReset bool
|
||||||
|
Error string
|
||||||
|
ReturnPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
available, err := a.authService.BootstrapAvailable()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to check registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
||||||
|
if err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
|
||||||
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := a.currentUser(r); ok {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := r.FormValue("next")
|
||||||
|
if next == "" {
|
||||||
|
next = "/app"
|
||||||
|
}
|
||||||
|
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
|
||||||
|
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.setUserSessionCookie(w, r, token)
|
||||||
|
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
|
||||||
|
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||||
|
_ = a.authService.Logout(cookie.Value)
|
||||||
|
}
|
||||||
|
a.clearUserSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
invite, err := a.authService.InviteByToken(r.PathValue("token"))
|
||||||
|
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
||||||
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.PathValue("token")
|
||||||
|
invite, err := a.authService.InviteByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
||||||
|
if err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
|
||||||
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
|
||||||
|
Title: "Account settings",
|
||||||
|
Description: "Manage your Warpbox account.",
|
||||||
|
CurrentUser: a.authService.PublicUser(user),
|
||||||
|
Data: user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
||||||
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
||||||
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
|
||||||
|
a.renderPage(w, r, status, "auth.html", web.PageData{
|
||||||
|
Title: "Account",
|
||||||
|
Description: "Sign in to Warpbox.",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
|
||||||
|
_, token, err := a.authService.Login(email, password)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.setUserSessionCookie(w, r, token)
|
||||||
|
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
||||||
|
cookie, err := r.Cookie(userSessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return services.User{}, false
|
||||||
|
}
|
||||||
|
user, _, err := a.authService.UserForSession(cookie.Value)
|
||||||
|
return user, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
||||||
|
user, ok := a.currentUser(r)
|
||||||
|
if ok {
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||||
|
return services.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: userSessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: userSessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeReturnPath(path string) string {
|
||||||
|
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
|
||||||
|
return "/app"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
179
backend/libs/handlers/dashboard.go
Normal file
179
backend/libs/handlers/dashboard.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
"warpbox.dev/backend/libs/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dashboardData struct {
|
||||||
|
User services.PublicUser
|
||||||
|
Collections []collectionView
|
||||||
|
Boxes []userBoxView
|
||||||
|
StorageUsed string
|
||||||
|
MaxUploadSize string
|
||||||
|
Selected string
|
||||||
|
LastInviteURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionView struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type userBoxView struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
CollectionID string
|
||||||
|
CollectionName string
|
||||||
|
FileCount int
|
||||||
|
Size string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collections, err := a.authService.ListCollections(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectionNames := map[string]string{}
|
||||||
|
collectionViews := make([]collectionView, 0, len(collections))
|
||||||
|
for _, collection := range collections {
|
||||||
|
collectionNames[collection.ID] = collection.Name
|
||||||
|
collectionViews = append(collectionViews, collectionView{ID: collection.ID, Name: collection.Name})
|
||||||
|
}
|
||||||
|
boxes, err := a.uploadService.UserBoxes(user.ID, collectionNames)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storageUsed, err := a.uploadService.UserStorageUsed(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load storage usage", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := r.URL.Query().Get("collection")
|
||||||
|
boxViews := make([]userBoxView, 0, len(boxes))
|
||||||
|
for _, row := range boxes {
|
||||||
|
if selected != "" && row.Box.CollectionID != selected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := row.Box.Title
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("%d file upload", len(row.Box.Files))
|
||||||
|
}
|
||||||
|
boxViews = append(boxViews, userBoxView{
|
||||||
|
ID: row.Box.ID,
|
||||||
|
Title: title,
|
||||||
|
CollectionID: row.Box.CollectionID,
|
||||||
|
CollectionName: row.CollectionName,
|
||||||
|
FileCount: len(row.Box.Files),
|
||||||
|
Size: row.TotalSizeLabel,
|
||||||
|
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
|
||||||
|
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"),
|
||||||
|
URL: "/d/" + row.Box.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderPage(w, r, http.StatusOK, "dashboard.html", web.PageData{
|
||||||
|
Title: "My files",
|
||||||
|
Description: "Your Warpbox personal file space.",
|
||||||
|
CurrentUser: a.authService.PublicUser(user),
|
||||||
|
Data: dashboardData{
|
||||||
|
User: a.authService.PublicUser(user),
|
||||||
|
Collections: collectionViews,
|
||||||
|
Boxes: boxViews,
|
||||||
|
StorageUsed: helpers.FormatBytes(storageUsed),
|
||||||
|
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||||
|
Selected: selected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||||
|
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||||
|
a.handleUserBoxError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectionID := r.FormValue("collection_id")
|
||||||
|
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||||
|
http.Error(w, "collection not found", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||||
|
a.handleUserBoxError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||||
|
a.handleUserBoxError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUserBoxError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
http.Error(w, "not allowed", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "unable to update box", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
if err := a.uploadService.CanDownload(box); err != nil {
|
||||||
a.renderer.Render(w, http.StatusForbidden, "download.html", web.PageData{
|
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
|
||||||
Title: "Download unavailable",
|
Title: "Download unavailable",
|
||||||
Description: "This Warpbox link is no longer available.",
|
Description: "This Warpbox link is no longer available.",
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
@@ -74,7 +74,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
||||||
Title: "Download files",
|
Title: "Download files",
|
||||||
Description: "Download files shared through Warpbox.",
|
Description: "Download files shared through Warpbox.",
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
@@ -107,7 +107,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "manage.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "manage.html", web.PageData{
|
||||||
Title: "Manage upload",
|
Title: "Manage upload",
|
||||||
Description: "Delete this anonymous Warpbox upload.",
|
Description: "Delete this anonymous Warpbox upload.",
|
||||||
Data: a.managePageData(box, r.PathValue("token")),
|
Data: a.managePageData(box, r.PathValue("token")),
|
||||||
@@ -48,7 +48,7 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
||||||
a.renderer.Render(w, http.StatusOK, "manage_deleted.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "manage_deleted.html", web.PageData{
|
||||||
Title: "Upload deleted",
|
Title: "Upload deleted",
|
||||||
Description: "This Warpbox upload has been deleted.",
|
Description: "This Warpbox upload has been deleted.",
|
||||||
Data: boxView{ID: r.PathValue("boxID")},
|
Data: boxView{ID: r.PathValue("boxID")},
|
||||||
|
|||||||
@@ -3,19 +3,69 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
type homeData struct {
|
type homeData struct {
|
||||||
MaxUploadSize string
|
MaxUploadSize string
|
||||||
|
LimitSummary string
|
||||||
|
Collections []collectionView
|
||||||
|
IsAdmin bool
|
||||||
|
AnonymousOpen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
|
currentUser := a.currentPublicUser(r)
|
||||||
|
var collections []collectionView
|
||||||
|
var isAdmin bool
|
||||||
|
var user services.User
|
||||||
|
var loggedIn bool
|
||||||
|
if current, ok := a.currentUser(r); ok {
|
||||||
|
user = current
|
||||||
|
loggedIn = true
|
||||||
|
isAdmin = user.Role == services.UserRoleAdmin
|
||||||
|
userCollections, err := a.authService.ListCollections(user.ID)
|
||||||
|
if err == nil {
|
||||||
|
collections = make([]collectionView, 0, len(userCollections))
|
||||||
|
for _, collection := range userCollections {
|
||||||
|
collections = append(collections, collectionView{ID: collection.ID, Name: collection.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||||
|
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||||
Title: "Upload your files",
|
Title: "Upload your files",
|
||||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||||
|
CurrentUser: currentUser,
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
MaxUploadSize: maxUploadSize,
|
||||||
|
LimitSummary: limitSummary,
|
||||||
|
Collections: collections,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
AnonymousOpen: settings.AnonymousUploadsEnabled,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
|
||||||
|
if isAdmin {
|
||||||
|
return "No file size limit", "Admin uploads bypass storage and daily caps."
|
||||||
|
}
|
||||||
|
if !loggedIn {
|
||||||
|
if !settings.AnonymousUploadsEnabled {
|
||||||
|
return "Anonymous uploads disabled", "Sign in to upload files."
|
||||||
|
}
|
||||||
|
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP."
|
||||||
|
}
|
||||||
|
quotaMB := settings.DefaultUserStorageMB
|
||||||
|
if user.StorageQuotaMB != nil {
|
||||||
|
quotaMB = *user.StorageQuotaMB
|
||||||
|
}
|
||||||
|
return a.uploadService.MaxUploadSizeLabel(), "Daily cap: " + services.FormatMegabytesLabel(settings.UserDailyUploadMB) + " · Storage quota: " + services.FormatMegabytesLabel(quotaMB) + "."
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
"warpbox.dev/backend/libs/jobs"
|
"warpbox.dev/backend/libs/jobs"
|
||||||
@@ -14,24 +15,71 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
|
user, loggedIn := a.currentUser(r)
|
||||||
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil {
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||||
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
|
||||||
|
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
||||||
|
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isAdminUpload {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize()))
|
||||||
|
}
|
||||||
|
parseLimit := uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize())
|
||||||
|
if isAdminUpload {
|
||||||
|
parseLimit = 32 << 20
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
files := uploadFiles(r)
|
files := uploadFiles(r)
|
||||||
|
totalBytes := totalUploadBytes(files)
|
||||||
|
var ownerID string
|
||||||
|
var collectionID string
|
||||||
|
if loggedIn {
|
||||||
|
ownerID = user.ID
|
||||||
|
collectionID = r.FormValue("collection_id")
|
||||||
|
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||||
|
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, files, totalBytes); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||||
MaxDays: parseInt(r.FormValue("max_days")),
|
MaxDays: parseInt(r.FormValue("max_days")),
|
||||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||||
Password: r.FormValue("password"),
|
Password: r.FormValue("password"),
|
||||||
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
||||||
|
OwnerID: ownerID,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
SkipSizeLimit: isAdminUpload,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
||||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes); err != nil {
|
||||||
|
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
|
||||||
|
}
|
||||||
|
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
||||||
|
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4403, "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||||
|
|
||||||
if wantsJSON(r) {
|
if wantsJSON(r) {
|
||||||
@@ -44,6 +92,76 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = fmt.Fprintln(w, result.BoxURL)
|
_, _ = fmt.Fprintln(w, result.BoxURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !loggedIn {
|
||||||
|
anonymousMaxBytes := services.MegabytesToBytes(settings.AnonymousMaxUploadMB)
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Size > anonymousMaxBytes {
|
||||||
|
return http.StatusRequestEntityTooLarge, "file exceeds anonymous upload size limit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||||
|
}
|
||||||
|
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.AnonymousDailyUploadMB) {
|
||||||
|
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := a.settingsService.UsageForUser(user.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||||
|
}
|
||||||
|
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.UserDailyUploadMB) {
|
||||||
|
return http.StatusTooManyRequests, "daily upload limit reached"
|
||||||
|
}
|
||||||
|
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "storage quota could not be checked"
|
||||||
|
}
|
||||||
|
quotaMB := settings.DefaultUserStorageMB
|
||||||
|
if user.StorageQuotaMB != nil {
|
||||||
|
quotaMB = *user.StorageQuotaMB
|
||||||
|
}
|
||||||
|
if activeStorage+totalBytes > services.MegabytesToBytes(quotaMB) {
|
||||||
|
return http.StatusRequestEntityTooLarge, "storage quota reached"
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if loggedIn {
|
||||||
|
return a.settingsService.AddUsage("user", user.ID, totalBytes, now)
|
||||||
|
}
|
||||||
|
return a.settingsService.AddUsage("ip", uploadClientIP(r), totalBytes, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadParseLimit(settings services.UploadPolicySettings, loggedIn bool, fallback int64) int64 {
|
||||||
|
if loggedIn {
|
||||||
|
return fallback * 8
|
||||||
|
}
|
||||||
|
return services.MegabytesToBytes(settings.AnonymousMaxUploadMB) * 8
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadClientIP(r *http.Request) string {
|
||||||
|
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, file := range files {
|
||||||
|
total += file.Size
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
func parseInt(value string) int {
|
func parseInt(value string) int {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
StaticDir: staticDir,
|
StaticDir: staticDir,
|
||||||
TemplateDir: templateDir,
|
TemplateDir: templateDir,
|
||||||
MaxUploadSize: 1024 * 1024,
|
MaxUploadSize: 1024 * 1024,
|
||||||
|
DefaultSettings: config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 1,
|
||||||
|
AnonymousDailyUploadMB: 8,
|
||||||
|
UserDailyUploadMB: 8,
|
||||||
|
DefaultUserStorageMB: 16,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -194,7 +202,17 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
service.Close()
|
service.Close()
|
||||||
t.Fatalf("NewRenderer returned error: %v", err)
|
t.Fatalf("NewRenderer returned error: %v", err)
|
||||||
}
|
}
|
||||||
return NewApp(cfg, logger, renderer, service), func() {
|
authService, err := services.NewAuthService(service.DB(), cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("NewAuthService returned error: %v", err)
|
||||||
|
}
|
||||||
|
settingsService, err := services.NewSettingsService(service.DB(), cfg.DefaultSettings)
|
||||||
|
if err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() {
|
||||||
if err := service.Close(); err != nil {
|
if err := service.Close(); err != nil {
|
||||||
t.Fatalf("Close returned error: %v", err)
|
t.Fatalf("Close returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
authService, err := services.NewAuthService(uploadService.DB(), cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
uploadService.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settingsService, err := services.NewSettingsService(uploadService.DB(), cfg.DefaultSettings)
|
||||||
|
if err != nil {
|
||||||
|
uploadService.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
stopJobs := jobs.StartAll(cfg, logger, uploadService)
|
stopJobs := jobs.StartAll(cfg, logger, uploadService)
|
||||||
app := handlers.NewApp(cfg, logger, renderer, uploadService)
|
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService)
|
||||||
|
|
||||||
router := http.NewServeMux()
|
router := http.NewServeMux()
|
||||||
app.RegisterRoutes(router)
|
app.RegisterRoutes(router)
|
||||||
|
|||||||
595
backend/libs/services/auth.go
Normal file
595
backend/libs/services/auth.go
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
usersBucket = []byte("users")
|
||||||
|
userEmailsBucket = []byte("user_emails")
|
||||||
|
sessionsBucket = []byte("sessions")
|
||||||
|
invitesBucket = []byte("invites")
|
||||||
|
collectionsBucket = []byte("collections")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserRoleAdmin = "admin"
|
||||||
|
UserRoleUser = "user"
|
||||||
|
|
||||||
|
UserStatusActive = "active"
|
||||||
|
UserStatusDisabled = "disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrRegistrationClosed = errors.New("registration is closed")
|
||||||
|
ErrInviteInvalid = errors.New("invite is invalid")
|
||||||
|
ErrUserDisabled = errors.New("user is disabled")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"passwordHash"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicUser struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Status string
|
||||||
|
StorageQuotaMB *float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
TokenHash string `json:"tokenHash"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
TokenHash string `json:"tokenHash"`
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
UsedAt *time.Time `json:"usedAt,omitempty"`
|
||||||
|
UsedByUserID string `json:"usedByUserId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteResult struct {
|
||||||
|
Invite Invite
|
||||||
|
URL string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
||||||
|
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
||||||
|
err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} {
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) BootstrapAvailable() (bool, error) {
|
||||||
|
count := 0
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(usersBucket).ForEach(func(_, _ []byte) error {
|
||||||
|
count++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count == 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreateBootstrapUser(username, email, password string) (User, error) {
|
||||||
|
available, err := s.BootstrapAvailable()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
return User{}, ErrRegistrationClosed
|
||||||
|
}
|
||||||
|
return s.createUser(username, email, password, UserRoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(email, password string) (User, string, error) {
|
||||||
|
user, err := s.UserByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return User{}, "", ErrUserDisabled
|
||||||
|
}
|
||||||
|
if !VerifyPasswordHash(user.PasswordHash, password) {
|
||||||
|
return User{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
token := randomID(32)
|
||||||
|
session := Session{
|
||||||
|
ID: randomID(12),
|
||||||
|
UserID: user.ID,
|
||||||
|
TokenHash: tokenHash(token),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
data, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(sessionsBucket).Put([]byte(session.ID), data)
|
||||||
|
})
|
||||||
|
return user, session.ID + "." + token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UserForSession(raw string) (User, Session, error) {
|
||||||
|
sessionID, token, ok := strings.Cut(raw, ".")
|
||||||
|
if !ok || sessionID == "" || token == "" {
|
||||||
|
return User{}, Session{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
var session Session
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(sessionsBucket).Get([]byte(sessionID))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &session)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, Session{}, err
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(tokenHash(token)), []byte(session.TokenHash)) != 1 {
|
||||||
|
return User{}, Session{}, os.ErrPermission
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, Session{}, err
|
||||||
|
}
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return User{}, Session{}, ErrUserDisabled
|
||||||
|
}
|
||||||
|
return user, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Logout(raw string) error {
|
||||||
|
sessionID, _, ok := strings.Cut(raw, ".")
|
||||||
|
if !ok || sessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(sessionsBucket).Delete([]byte(sessionID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
|
||||||
|
email, err := normalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
role = UserRoleUser
|
||||||
|
}
|
||||||
|
if role != UserRoleAdmin && role != UserRoleUser {
|
||||||
|
role = UserRoleUser
|
||||||
|
}
|
||||||
|
if expiresIn <= 0 {
|
||||||
|
expiresIn = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
token := randomID(32)
|
||||||
|
invite := Invite{
|
||||||
|
ID: randomID(12),
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
TokenHash: tokenHash(token),
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(expiresIn),
|
||||||
|
}
|
||||||
|
err = s.saveInvite(invite)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
return InviteResult{
|
||||||
|
Invite: invite,
|
||||||
|
Token: token,
|
||||||
|
URL: fmt.Sprintf("%s/invite/%s", s.baseURL, token),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) AcceptInvite(token, username, password string) (User, error) {
|
||||||
|
invite, err := s.InviteByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
||||||
|
return User{}, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if invite.UserID != "" {
|
||||||
|
user, err = s.UserByID(invite.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.SetPassword(user.ID, password); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user, _ = s.UserByID(user.ID)
|
||||||
|
} else {
|
||||||
|
user, err = s.createUser(username, invite.Email, password, invite.Role)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
invite.UsedAt = &now
|
||||||
|
invite.UsedByUserID = user.ID
|
||||||
|
if err := s.saveInvite(invite); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) InviteByToken(token string) (Invite, error) {
|
||||||
|
hash := tokenHash(token)
|
||||||
|
var match Invite
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(invitesBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var invite Invite
|
||||||
|
if err := json.Unmarshal(value, &invite); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(hash), []byte(invite.TokenHash)) == 1 {
|
||||||
|
match = invite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Invite{}, err
|
||||||
|
}
|
||||||
|
if match.ID == "" {
|
||||||
|
return Invite{}, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreatePasswordResetInvite(userID, createdBy string) (InviteResult, error) {
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
result, err := s.CreateInvite(user.Email, user.Role, createdBy, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
result.Invite.UserID = user.ID
|
||||||
|
if err := s.saveInvite(result.Invite); err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ListUsers() ([]User, error) {
|
||||||
|
users := make([]User, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(usersBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var user User
|
||||||
|
if err := json.Unmarshal(value, &user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(users, func(i, j int) bool {
|
||||||
|
return users[i].CreatedAt.After(users[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) DisableUser(userID string, disabled bool) error {
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
user.Status = UserStatusDisabled
|
||||||
|
} else {
|
||||||
|
user.Status = UserStatusActive
|
||||||
|
}
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetPassword(userID, password string) error {
|
||||||
|
if len(password) < 8 {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.PasswordHash = HashPassword(password)
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error {
|
||||||
|
if quotaMB != nil && *quotaMB <= 0 {
|
||||||
|
return fmt.Errorf("storage quota must be positive")
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.StorageQuotaMB = quotaMB
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UserByID(id string) (User, error) {
|
||||||
|
var user User
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(usersBucket).Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &user)
|
||||||
|
})
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UserByEmail(email string) (User, error) {
|
||||||
|
email, err := normalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
var userID string
|
||||||
|
err = s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(userEmailsBucket).Get([]byte(email))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
userID = string(data)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return s.UserByID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreateCollection(userID, name string) (Collection, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return Collection{}, fmt.Errorf("collection name is required")
|
||||||
|
}
|
||||||
|
collection := Collection{
|
||||||
|
ID: randomID(10),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
return collection, s.saveCollection(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ListCollections(userID string) ([]Collection, error) {
|
||||||
|
collections := make([]Collection, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(collectionsBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var collection Collection
|
||||||
|
if err := json.Unmarshal(value, &collection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if collection.UserID == userID {
|
||||||
|
collections = append(collections, collection)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(collections, func(i, j int) bool {
|
||||||
|
return strings.ToLower(collections[i].Name) < strings.ToLower(collections[j].Name)
|
||||||
|
})
|
||||||
|
return collections, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CollectionOwnedBy(collectionID, userID string) bool {
|
||||||
|
if collectionID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
collection, err := s.CollectionByID(collectionID)
|
||||||
|
return err == nil && collection.UserID == userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CollectionByID(id string) (Collection, error) {
|
||||||
|
var collection Collection
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(collectionsBucket).Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &collection)
|
||||||
|
})
|
||||||
|
return collection, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) PublicUser(user User) PublicUser {
|
||||||
|
return PublicUser{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Role: user.Role,
|
||||||
|
Status: user.Status,
|
||||||
|
StorageQuotaMB: user.StorageQuotaMB,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) createUser(username, email, password, role string) (User, error) {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if username == "" {
|
||||||
|
return User{}, fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
email, err := normalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if len(password) < 8 {
|
||||||
|
return User{}, fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
if role != UserRoleAdmin && role != UserRoleUser {
|
||||||
|
role = UserRoleUser
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
user := User{
|
||||||
|
ID: randomID(12),
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: HashPassword(password),
|
||||||
|
Role: role,
|
||||||
|
Status: UserStatusActive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
return user, s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
if existing := tx.Bucket(userEmailsBucket).Get([]byte(email)); existing != nil {
|
||||||
|
return fmt.Errorf("email is already registered")
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Bucket(usersBucket).Put([]byte(user.ID), data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(userEmailsBucket).Put([]byte(email), []byte(user.ID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveUser(user User) error {
|
||||||
|
data, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(usersBucket).Put([]byte(user.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveInvite(invite Invite) error {
|
||||||
|
data, err := json.Marshal(invite)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(invitesBucket).Put([]byte(invite.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveCollection(collection Collection) error {
|
||||||
|
data, err := json.Marshal(collection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(collectionsBucket).Put([]byte(collection.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEmail(email string) (string, error) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if email == "" {
|
||||||
|
return "", fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return "", fmt.Errorf("email is invalid")
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenHash(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte("warpbox-session:" + token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) string {
|
||||||
|
salt := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
salt = []byte(randomID(16))[:16]
|
||||||
|
}
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||||
|
return "argon2id$v=19$m=65536,t=1,p=4$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPasswordHash(encoded, password string) bool {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 5 || parts[0] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expected, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
|
||||||
|
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||||
|
}
|
||||||
123
backend/libs/services/auth_test.go
Normal file
123
backend/libs/services/auth_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordHashVerification(t *testing.T) {
|
||||||
|
hash := HashPassword("correct-horse")
|
||||||
|
if !VerifyPasswordHash(hash, "correct-horse") {
|
||||||
|
t.Fatalf("VerifyPasswordHash rejected the correct password")
|
||||||
|
}
|
||||||
|
if VerifyPasswordHash(hash, "wrong-password") {
|
||||||
|
t.Fatalf("VerifyPasswordHash accepted the wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapCreatesAdminAndClosesRegistration(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
available, err := auth.BootstrapAvailable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapAvailable returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
t.Fatalf("BootstrapAvailable = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != UserRoleAdmin {
|
||||||
|
t.Fatalf("role = %q, want admin", user.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := auth.CreateBootstrapUser("other", "other@example.test", "password123"); err == nil {
|
||||||
|
t.Fatalf("second bootstrap unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSessionAndDisabledUser(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Login("daniel@example.test", "wrong"); err == nil {
|
||||||
|
t.Fatalf("Login accepted wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, token, err := auth.Login("daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
sessionUser, _, err := auth.UserForSession(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserForSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if sessionUser.ID != user.ID {
|
||||||
|
t.Fatalf("session user = %q, want %q", sessionUser.ID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.DisableUser(user.ID, true); err != nil {
|
||||||
|
t.Fatalf("DisableUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.UserForSession(token); err == nil {
|
||||||
|
t.Fatalf("disabled user session still resolved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
admin, err := auth.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := auth.CreateInvite("friend@example.test", UserRoleUser, admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
user, err := auth.AcceptInvite(invite.Token, "friend", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
if user.Email != "friend@example.test" {
|
||||||
|
t.Fatalf("email = %q, want friend@example.test", user.Email)
|
||||||
|
}
|
||||||
|
if _, err := auth.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
|
||||||
|
t.Fatalf("AcceptInvite allowed token reuse")
|
||||||
|
}
|
||||||
|
|
||||||
|
reset, err := auth.CreatePasswordResetInvite(user.ID, admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePasswordResetInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := auth.AcceptInvite(reset.Token, "", "newpassword123"); err != nil {
|
||||||
|
t.Fatalf("AcceptInvite reset returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Login("friend@example.test", "newpassword123"); err != nil {
|
||||||
|
t.Fatalf("Login with reset password returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestAuthService(t *testing.T) *AuthService {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := upload.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
auth, err := NewAuthService(upload.DB(), "http://example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAuthService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
245
backend/libs/services/settings.go
Normal file
245
backend/libs/services/settings.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"warpbox.dev/backend/libs/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
settingsBucket = []byte("settings")
|
||||||
|
usageBucket = []byte("usage")
|
||||||
|
)
|
||||||
|
|
||||||
|
var settingsKey = []byte("upload_policy")
|
||||||
|
|
||||||
|
type UploadPolicySettings struct {
|
||||||
|
AnonymousUploadsEnabled bool `json:"anonymousUploadsEnabled"`
|
||||||
|
AnonymousMaxUploadMB float64 `json:"anonymousMaxUploadMb"`
|
||||||
|
AnonymousDailyUploadMB float64 `json:"anonymousDailyUploadMb"`
|
||||||
|
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
|
||||||
|
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
|
||||||
|
UsageRetentionDays int `json:"usageRetentionDays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageRecord struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
SubjectType string `json:"subjectType"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
UploadedBytes int64 `json:"uploadedBytes"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
defaults UploadPolicySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*SettingsService, error) {
|
||||||
|
service := &SettingsService{
|
||||||
|
db: db,
|
||||||
|
defaults: UploadPolicySettings{
|
||||||
|
AnonymousUploadsEnabled: defaults.AnonymousUploadsEnabled,
|
||||||
|
AnonymousMaxUploadMB: defaults.AnonymousMaxUploadMB,
|
||||||
|
AnonymousDailyUploadMB: defaults.AnonymousDailyUploadMB,
|
||||||
|
UserDailyUploadMB: defaults.UserDailyUploadMB,
|
||||||
|
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
|
||||||
|
UsageRetentionDays: defaults.UsageRetentionDays,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := service.validate(service.defaults); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, bucket := range [][]byte{settingsBucket, usageBucket} {
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
||||||
|
settings := s.defaults
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(settingsBucket).Get(settingsKey)
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &settings)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return UploadPolicySettings{}, err
|
||||||
|
}
|
||||||
|
if err := s.validate(settings); err != nil {
|
||||||
|
return UploadPolicySettings{}, err
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
|
||||||
|
if err := s.validate(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(settingsBucket).Put(settingsKey, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
|
||||||
|
key := usageKey(subjectType, subject, now)
|
||||||
|
var record UsageRecord
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(usageBucket).Get([]byte(key))
|
||||||
|
if data == nil {
|
||||||
|
record = UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &record)
|
||||||
|
})
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
|
||||||
|
if bytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := usageKey(subjectType, subject, now)
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(usageBucket)
|
||||||
|
record := UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
|
||||||
|
data := bucket.Get([]byte(key))
|
||||||
|
if data != nil {
|
||||||
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.UploadedBytes += bytes
|
||||||
|
record.UpdatedAt = now.UTC()
|
||||||
|
next, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bucket.Put([]byte(key), next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
|
||||||
|
if retentionDays <= 0 {
|
||||||
|
return fmt.Errorf("usage retention days must be positive")
|
||||||
|
}
|
||||||
|
cutoff := now.UTC().AddDate(0, 0, -retentionDays)
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(usageBucket)
|
||||||
|
return bucket.ForEach(func(key, value []byte) error {
|
||||||
|
var record UsageRecord
|
||||||
|
if err := json.Unmarshal(value, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
date, err := time.Parse("2006-01-02", record.Date)
|
||||||
|
if err != nil || date.Before(cutoff) {
|
||||||
|
return bucket.Delete(key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UsageForUser(userID string, now time.Time) (UsageRecord, error) {
|
||||||
|
return s.Usage("user", userID, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, error) {
|
||||||
|
return s.Usage("ip", ip, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
||||||
|
if settings.AnonymousMaxUploadMB <= 0 {
|
||||||
|
return fmt.Errorf("anonymous max upload must be positive")
|
||||||
|
}
|
||||||
|
if settings.AnonymousDailyUploadMB <= 0 {
|
||||||
|
return fmt.Errorf("anonymous daily upload must be positive")
|
||||||
|
}
|
||||||
|
if settings.UserDailyUploadMB <= 0 {
|
||||||
|
return fmt.Errorf("user daily upload must be positive")
|
||||||
|
}
|
||||||
|
if settings.DefaultUserStorageMB <= 0 {
|
||||||
|
return fmt.Errorf("default user storage must be positive")
|
||||||
|
}
|
||||||
|
if settings.UsageRetentionDays <= 0 {
|
||||||
|
return fmt.Errorf("usage retention days must be positive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMegabytesValue(value string) (float64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, fmt.Errorf("megabyte value is required")
|
||||||
|
}
|
||||||
|
value = strings.TrimSuffix(value, "MB")
|
||||||
|
value = strings.TrimSuffix(value, "Mb")
|
||||||
|
value = strings.TrimSuffix(value, "mb")
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if parsed <= 0 {
|
||||||
|
return 0, fmt.Errorf("megabyte value must be positive")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MegabytesToBytes(value float64) int64 {
|
||||||
|
return int64(value * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatMegabytesFromBytes(value int64) string {
|
||||||
|
mb := float64(value) / 1024 / 1024
|
||||||
|
return FormatMegabytesLabel(mb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatMegabytesLabel(value float64) string {
|
||||||
|
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageKey(subjectType, subject string, now time.Time) string {
|
||||||
|
return subjectType + ":" + subject + ":" + usageDate(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageDate(now time.Time) string {
|
||||||
|
return now.UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientIP(remoteAddr, forwardedFor string) string {
|
||||||
|
if forwardedFor != "" {
|
||||||
|
parts := strings.Split(forwardedFor, ",")
|
||||||
|
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host := remoteAddr
|
||||||
|
if strings.Contains(remoteAddr, ":") {
|
||||||
|
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
|
host = splitHost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
173
backend/libs/services/settings_test.go
Normal file
173
backend/libs/services/settings_test.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsLoadDefaultsAndOverrides(t *testing.T) {
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
policy, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !policy.AnonymousUploadsEnabled || policy.AnonymousMaxUploadMB != 512 {
|
||||||
|
t.Fatalf("default policy = %+v", policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.AnonymousUploadsEnabled = false
|
||||||
|
policy.UserDailyUploadMB = 123
|
||||||
|
if err := settings.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
next, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
if next.AnonymousUploadsEnabled || next.UserDailyUploadMB != 123 {
|
||||||
|
t.Fatalf("override policy = %+v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsUseNewEnvDefaultsUntilSaved(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer upload.Close()
|
||||||
|
|
||||||
|
first, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 111,
|
||||||
|
AnonymousDailyUploadMB: 222,
|
||||||
|
UserDailyUploadMB: 333,
|
||||||
|
DefaultUserStorageMB: 444,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService first returned error: %v", err)
|
||||||
|
}
|
||||||
|
firstPolicy, err := first.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy first returned error: %v", err)
|
||||||
|
}
|
||||||
|
if firstPolicy.AnonymousMaxUploadMB != 111 {
|
||||||
|
t.Fatalf("first AnonymousMaxUploadMB = %v, want 111", firstPolicy.AnonymousMaxUploadMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 555,
|
||||||
|
AnonymousDailyUploadMB: 666,
|
||||||
|
UserDailyUploadMB: 777,
|
||||||
|
DefaultUserStorageMB: 888,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService second returned error: %v", err)
|
||||||
|
}
|
||||||
|
secondPolicy, err := second.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy second returned error: %v", err)
|
||||||
|
}
|
||||||
|
if secondPolicy.AnonymousMaxUploadMB != 555 {
|
||||||
|
t.Fatalf("second AnonymousMaxUploadMB = %v, want 555", secondPolicy.AnonymousMaxUploadMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := second.UpdateUploadPolicy(secondPolicy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
third, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 999,
|
||||||
|
AnonymousDailyUploadMB: 999,
|
||||||
|
UserDailyUploadMB: 999,
|
||||||
|
DefaultUserStorageMB: 999,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService third returned error: %v", err)
|
||||||
|
}
|
||||||
|
thirdPolicy, err := third.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy third returned error: %v", err)
|
||||||
|
}
|
||||||
|
if thirdPolicy.AnonymousMaxUploadMB != 555 {
|
||||||
|
t.Fatalf("third AnonymousMaxUploadMB = %v, want persisted 555", thirdPolicy.AnonymousMaxUploadMB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsRejectInvalidMegabytes(t *testing.T) {
|
||||||
|
if _, err := ParseMegabytesValue("0"); err == nil {
|
||||||
|
t.Fatalf("ParseMegabytesValue accepted zero")
|
||||||
|
}
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
policy, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy.DefaultUserStorageMB = -1
|
||||||
|
if err := settings.UpdateUploadPolicy(policy); err == nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy accepted negative storage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyUsageAndCleanup(t *testing.T) {
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
if err := settings.AddUsage("ip", "127.0.0.1", 1024, now); err != nil {
|
||||||
|
t.Fatalf("AddUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := settings.AddUsage("ip", "127.0.0.1", 2048, now); err != nil {
|
||||||
|
t.Fatalf("AddUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
usage, err := settings.UsageForIP("127.0.0.1", now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageForIP returned error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.UploadedBytes != 3072 {
|
||||||
|
t.Fatalf("UploadedBytes = %d, want 3072", usage.UploadedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := settings.CleanupUsage(now.AddDate(0, 0, 31), 30); err != nil {
|
||||||
|
t.Fatalf("CleanupUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
usage, err = settings.UsageForIP("127.0.0.1", now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageForIP returned error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.UploadedBytes != 0 {
|
||||||
|
t.Fatalf("UploadedBytes after cleanup = %d, want 0", usage.UploadedBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestSettingsService(t *testing.T) *SettingsService {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := upload.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
settings, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 512,
|
||||||
|
AnonymousDailyUploadMB: 2048,
|
||||||
|
UserDailyUploadMB: 8192,
|
||||||
|
DefaultUserStorageMB: 51200,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,10 +38,16 @@ type UploadOptions struct {
|
|||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
Password string
|
Password string
|
||||||
ObfuscateMetadata bool
|
ObfuscateMetadata bool
|
||||||
|
OwnerID string
|
||||||
|
CollectionID string
|
||||||
|
SkipSizeLimit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Box struct {
|
type Box struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
OwnerID string `json:"ownerId,omitempty"`
|
||||||
|
CollectionID string `json:"collectionId,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
MaxDownloads int `json:"maxDownloads"`
|
MaxDownloads int `json:"maxDownloads"`
|
||||||
@@ -93,6 +100,7 @@ type AdminStats struct {
|
|||||||
|
|
||||||
type AdminBox struct {
|
type AdminBox struct {
|
||||||
ID string
|
ID string
|
||||||
|
OwnerID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
FileCount int
|
FileCount int
|
||||||
@@ -104,6 +112,12 @@ type AdminBox struct {
|
|||||||
Expired bool
|
Expired bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserBox struct {
|
||||||
|
Box Box
|
||||||
|
CollectionName string
|
||||||
|
TotalSizeLabel string
|
||||||
|
}
|
||||||
|
|
||||||
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
||||||
filesDir := filepath.Join(dataDir, "files")
|
filesDir := filepath.Join(dataDir, "files")
|
||||||
dbDir := filepath.Join(dataDir, "db")
|
dbDir := filepath.Join(dataDir, "db")
|
||||||
@@ -141,6 +155,10 @@ func (s *UploadService) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) DB() *bbolt.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) MaxUploadSize() int64 {
|
func (s *UploadService) MaxUploadSize() int64 {
|
||||||
return s.maxUploadSize
|
return s.maxUploadSize
|
||||||
}
|
}
|
||||||
@@ -166,6 +184,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
|
|
||||||
box := Box{
|
box := Box{
|
||||||
ID: randomID(10),
|
ID: randomID(10),
|
||||||
|
OwnerID: strings.TrimSpace(opts.OwnerID),
|
||||||
|
CollectionID: strings.TrimSpace(opts.CollectionID),
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
||||||
MaxDownloads: opts.MaxDownloads,
|
MaxDownloads: opts.MaxDownloads,
|
||||||
@@ -186,8 +206,15 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, header := range files {
|
for _, header := range files {
|
||||||
if err := s.ValidateSize(header.Size); err != nil {
|
if !opts.SkipSizeLimit {
|
||||||
return UploadResult{}, err
|
if err := s.ValidateSize(header.Size); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := s.maxUploadSize
|
||||||
|
if opts.SkipSizeLimit {
|
||||||
|
maxSize = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := header.Open()
|
file, err := header.Open()
|
||||||
@@ -203,7 +230,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil {
|
if err := writeUploadedFile(storedPath, file, maxSize); err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
@@ -314,6 +341,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
|||||||
}
|
}
|
||||||
rows = append(rows, AdminBox{
|
rows = append(rows, AdminBox{
|
||||||
ID: box.ID,
|
ID: box.ID,
|
||||||
|
OwnerID: box.OwnerID,
|
||||||
CreatedAt: box.CreatedAt,
|
CreatedAt: box.CreatedAt,
|
||||||
ExpiresAt: box.ExpiresAt,
|
ExpiresAt: box.ExpiresAt,
|
||||||
FileCount: len(box.Files),
|
FileCount: len(box.Files),
|
||||||
@@ -328,6 +356,97 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
|
||||||
|
boxes, err := s.ListBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]UserBox, 0)
|
||||||
|
for _, box := range boxes {
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
for _, file := range box.Files {
|
||||||
|
size += file.Size
|
||||||
|
}
|
||||||
|
rows = append(rows, UserBox{
|
||||||
|
Box: box,
|
||||||
|
CollectionName: collectionNames[box.CollectionID],
|
||||||
|
TotalSizeLabel: helpers.FormatBytes(size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
|
||||||
|
})
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
|
||||||
|
return s.userStorageUsed(userID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
|
||||||
|
return s.userStorageUsed(userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
|
||||||
|
boxes, err := s.ListBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, box := range boxes {
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if activeOnly && !box.ExpiresAt.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, file := range box.Files {
|
||||||
|
total += file.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
box.Title = strings.TrimSpace(title)
|
||||||
|
return s.SaveBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
box.CollectionID = strings.TrimSpace(collectionID)
|
||||||
|
return s.SaveBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
return s.DeleteBoxWithSource(boxID, "user-delete")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) DeleteBox(boxID string) error {
|
func (s *UploadService) DeleteBox(boxID string) error {
|
||||||
return s.DeleteBoxWithSource(boxID, "admin")
|
return s.DeleteBoxWithSource(boxID, "admin")
|
||||||
}
|
}
|
||||||
@@ -518,12 +637,17 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
|
|||||||
}
|
}
|
||||||
defer target.Close()
|
defer target.Close()
|
||||||
|
|
||||||
written, err := io.Copy(target, io.LimitReader(source, maxSize+1))
|
var written int64
|
||||||
|
if maxSize <= 0 {
|
||||||
|
written, err = io.Copy(target, source)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if written > maxSize {
|
if maxSize > 0 && written > maxSize {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
return fmt.Errorf("file exceeds max upload size")
|
return fmt.Errorf("file exceeds max upload size")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeleteTokenVerification(t *testing.T) {
|
func TestDeleteTokenVerification(t *testing.T) {
|
||||||
@@ -59,6 +60,39 @@ func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
active, err := service.CreateBox(testFileHeaders(t, "file", "active.txt", "active"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBox active returned error: %v", err)
|
||||||
|
}
|
||||||
|
expired, err := service.CreateBox(testFileHeaders(t, "file", "expired.txt", "expired"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBox expired returned error: %v", err)
|
||||||
|
}
|
||||||
|
expiredBox, err := service.GetBox(expired.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
expiredBox.ExpiresAt = time.Now().UTC().Add(-time.Hour)
|
||||||
|
if err := service.SaveBox(expiredBox); err != nil {
|
||||||
|
t.Fatalf("SaveBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBox, err := service.GetBox(active.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox active returned error: %v", err)
|
||||||
|
}
|
||||||
|
want := activeBox.Files[0].Size
|
||||||
|
got, err := service.UserActiveStorageUsed("user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserActiveStorageUsed returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("UserActiveStorageUsed = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestUploadService(t *testing.T) *UploadService {
|
func newTestUploadService(t *testing.T) *UploadService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type PageData struct {
|
|||||||
Description string
|
Description string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
|
CurrentUser any
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: rgba(9, 9, 11, 0.84);
|
background: rgba(9, 9, 11, 0.84);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
@@ -90,12 +93,17 @@ svg {
|
|||||||
|
|
||||||
.brand,
|
.brand,
|
||||||
.nav-links,
|
.nav-links,
|
||||||
.footer-links {
|
.footer-links,
|
||||||
|
.inline-form {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -171,6 +179,187 @@ h1 {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-view {
|
||||||
|
width: min(28rem, calc(100% - 2rem));
|
||||||
|
min-height: calc(100vh - 7.25rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-copy,
|
||||||
|
.auth-alt {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-form label,
|
||||||
|
.inline-controls label,
|
||||||
|
.collection-create label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
width: min(86rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14rem minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 5rem;
|
||||||
|
align-self: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(24, 24, 27, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
padding: 0.62rem 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover,
|
||||||
|
.sidebar-link.is-active {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .app-sidebar {
|
||||||
|
border-color: rgba(125, 211, 252, 0.28);
|
||||||
|
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .sidebar-link.is-active {
|
||||||
|
border-color: rgba(125, 211, 252, 0.42);
|
||||||
|
background: rgba(14, 116, 144, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .kicker {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout {
|
||||||
|
display: grid;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-create {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-upload .drop-zone {
|
||||||
|
min-height: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-options {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-tabs,
|
||||||
|
.inline-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-controls input,
|
||||||
|
.inline-controls select {
|
||||||
|
min-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-narrow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .checkbox-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
min-height: 19rem;
|
min-height: 19rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1034,7 +1223,9 @@ pre code {
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: none;
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-view,
|
.upload-view,
|
||||||
@@ -1057,10 +1248,16 @@ pre code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.docs-grid,
|
.docs-grid,
|
||||||
.field-grid {
|
.field-grid,
|
||||||
|
.app-shell,
|
||||||
|
.settings-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
.endpoint-list div {
|
.endpoint-list div {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|||||||
@@ -27,8 +27,13 @@
|
|||||||
<span>{{.AppName}}</span>
|
<span>{{.AppName}}</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
|
{{if .CurrentUser}}
|
||||||
<a class="button button-ghost" href="/api">API</a>
|
<a class="button button-ghost" href="/api">API</a>
|
||||||
<a class="button button-outline" href="/healthz">Health</a>
|
<a class="button button-outline" href="/account/settings">My Account</a>
|
||||||
|
{{else}}
|
||||||
|
<a class="button button-ghost" href="/login">Login</a>
|
||||||
|
<a class="button button-ghost" href="/api">API</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -39,7 +44,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
|
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
|
||||||
<span class="footer-links"><a href="/">Upload</a><a href="/healthz">Health</a></span>
|
<span class="footer-links">{{if .CurrentUser}}<a href="/api">API</a><a href="/account/settings">My Account</a>{{else}}<a href="/login">Login</a><a href="/api">API</a>{{end}}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
43
backend/templates/pages/account.html
Normal file
43
backend/templates/pages/account.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "account.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="app-shell" aria-labelledby="account-title">
|
||||||
|
<aside class="app-sidebar">
|
||||||
|
<a class="sidebar-link" href="/app">My files</a>
|
||||||
|
<a class="sidebar-link is-active" href="/account/settings">Account settings</a>
|
||||||
|
{{if eq .Data.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
|
||||||
|
<form class="sidebar-logout" action="/logout" method="post">
|
||||||
|
<button class="button button-outline" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<div class="admin-header">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Account</p>
|
||||||
|
<h1 id="account-title">Settings</h1>
|
||||||
|
<p class="muted-copy">{{.Data.Email}} · {{.Data.Role}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="card settings-panel">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="table-header">
|
||||||
|
<div>
|
||||||
|
<h2>Password</h2>
|
||||||
|
<p>Update the password for your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form class="settings-form settings-form-narrow" action="/account/password" method="post">
|
||||||
|
<label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label>
|
||||||
|
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
|
||||||
|
<button class="button button-primary" type="submit">Update password</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
{{define "admin.html"}}{{template "base" .}}{{end}}
|
{{define "admin.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="admin-view" aria-labelledby="admin-title">
|
<section class="app-shell admin-shell" aria-labelledby="admin-title">
|
||||||
|
<aside class="app-sidebar">
|
||||||
|
<a class="sidebar-link {{if eq .Data.Section "overview"}}is-active{{end}}" href="/admin">Overview</a>
|
||||||
|
<a class="sidebar-link {{if eq .Data.Section "files"}}is-active{{end}}" href="/admin/files">Files</a>
|
||||||
|
<a class="sidebar-link" href="/admin/users">Users</a>
|
||||||
|
<a class="sidebar-link" href="/admin/settings">Settings</a>
|
||||||
|
<a class="sidebar-link" href="/app">My files</a>
|
||||||
|
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||||
|
<button class="button button-outline" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="kicker">Operator console</p>
|
<p class="kicker">Operator console</p>
|
||||||
<h1 id="admin-title">Admin overview</h1>
|
<h1 id="admin-title">{{.Data.PageTitle}}</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action="/admin/logout" method="post">
|
|
||||||
<button class="button button-outline" type="submit">Logout</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
@@ -54,6 +63,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Box</th>
|
<th>Box</th>
|
||||||
|
<th>Owner</th>
|
||||||
<th>Files</th>
|
<th>Files</th>
|
||||||
<th>Size</th>
|
<th>Size</th>
|
||||||
<th>Downloads</th>
|
<th>Downloads</th>
|
||||||
@@ -67,6 +77,7 @@
|
|||||||
{{range .Data.Boxes}}
|
{{range .Data.Boxes}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{.ID}}</code></td>
|
<td><code>{{.ID}}</code></td>
|
||||||
|
<td>{{.Owner}}</td>
|
||||||
<td>{{.FileCount}}</td>
|
<td>{{.FileCount}}</td>
|
||||||
<td>{{.TotalSizeLabel}}</td>
|
<td>{{.TotalSizeLabel}}</td>
|
||||||
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
|
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
|
||||||
@@ -84,12 +95,13 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="8">No uploads yet.</td></tr>
|
<tr><td colspan="9">No uploads yet.</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
64
backend/templates/pages/admin_settings.html
Normal file
64
backend/templates/pages/admin_settings.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{{define "admin_settings.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="app-shell admin-shell" aria-labelledby="admin-settings-title">
|
||||||
|
<aside class="app-sidebar">
|
||||||
|
<a class="sidebar-link" href="/admin">Overview</a>
|
||||||
|
<a class="sidebar-link" href="/admin/files">Files</a>
|
||||||
|
<a class="sidebar-link" href="/admin/users">Users</a>
|
||||||
|
<a class="sidebar-link is-active" href="/admin/settings">Settings</a>
|
||||||
|
<a class="sidebar-link" href="/app">My files</a>
|
||||||
|
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||||
|
<button class="button button-outline" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<div class="admin-header">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Operator console</p>
|
||||||
|
<h1 id="admin-settings-title">{{.Data.PageTitle}}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card admin-table-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="table-header">
|
||||||
|
<div>
|
||||||
|
<h2>Upload policy</h2>
|
||||||
|
<p>Values are stored in megabytes. Admin users bypass these upload caps.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="settings-form" action="/admin/settings" method="post">
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input type="checkbox" name="anonymous_uploads_enabled" {{if .Data.Settings.AnonymousUploadsEnabled}}checked{{end}}>
|
||||||
|
<span>Allow anonymous uploads</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Anonymous max upload MB</span>
|
||||||
|
<input name="anonymous_max_upload_mb" value="{{.Data.Settings.AnonymousMaxUploadMB}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Anonymous daily upload MB per IP</span>
|
||||||
|
<input name="anonymous_daily_upload_mb" value="{{.Data.Settings.AnonymousDailyUploadMB}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>User daily upload MB</span>
|
||||||
|
<input name="user_daily_upload_mb" value="{{.Data.Settings.UserDailyUploadMB}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Default user storage MB</span>
|
||||||
|
<input name="default_user_storage_mb" value="{{.Data.Settings.DefaultUserStorageMB}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Usage retention days</span>
|
||||||
|
<input type="number" name="usage_retention_days" min="1" value="{{.Data.Settings.UsageRetentionDays}}" required>
|
||||||
|
</label>
|
||||||
|
<button class="button button-primary" type="submit">Save settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
82
backend/templates/pages/admin_users.html
Normal file
82
backend/templates/pages/admin_users.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{{define "admin_users.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="app-shell admin-shell" aria-labelledby="admin-users-title">
|
||||||
|
<aside class="app-sidebar">
|
||||||
|
<a class="sidebar-link" href="/admin">Overview</a>
|
||||||
|
<a class="sidebar-link" href="/admin/files">Files</a>
|
||||||
|
<a class="sidebar-link is-active" href="/admin/users">Users</a>
|
||||||
|
<a class="sidebar-link" href="/admin/settings">Settings</a>
|
||||||
|
<a class="sidebar-link" href="/app">My files</a>
|
||||||
|
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||||
|
<button class="button button-outline" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<div class="admin-header">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Operator console</p>
|
||||||
|
<h1 id="admin-users-title">{{.Data.PageTitle}}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card admin-table-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="table-header">
|
||||||
|
<div>
|
||||||
|
<h2>Create invite</h2>
|
||||||
|
<p>Copy the generated link and send it manually. SMTP delivery comes later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Data.LastInviteURL}}
|
||||||
|
<p class="manage-link"><span>Invite link:</span> <a href="{{.Data.LastInviteURL}}">{{.Data.LastInviteURL}}</a></p>
|
||||||
|
{{end}}
|
||||||
|
<form class="inline-controls" action="/admin/invites" method="post">
|
||||||
|
<label><span>Email</span><input type="email" name="email" required></label>
|
||||||
|
<label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label>
|
||||||
|
<button class="button button-primary" type="submit">Create invite</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card admin-table-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Storage</th><th>Today</th><th>Joined</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Users}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td>{{.Role}}</td>
|
||||||
|
<td><span class="badge">{{.Status}}</span></td>
|
||||||
|
<td>{{.StorageUsed}} / {{.StorageQuota}}</td>
|
||||||
|
<td>{{.DailyUsed}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td class="table-actions">
|
||||||
|
{{if eq .Status "disabled"}}
|
||||||
|
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"><button class="button button-outline" type="submit">Reactivate</button></form>
|
||||||
|
{{else}}
|
||||||
|
<form action="/admin/users/{{.ID}}/disable" method="post"><button class="button button-danger" type="submit">Disable</button></form>
|
||||||
|
{{end}}
|
||||||
|
<form action="/admin/users/{{.ID}}/reset" method="post"><button class="button button-outline" type="submit">Reset link</button></form>
|
||||||
|
<form action="/admin/users/{{.ID}}/quota" method="post">
|
||||||
|
<input class="compact-input" name="storage_quota_mb" placeholder="Quota MB">
|
||||||
|
<button class="button button-outline" type="submit">Quota</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="8">No users yet.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
43
backend/templates/pages/auth.html
Normal file
43
backend/templates/pages/auth.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{{define "auth.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="auth-view" aria-labelledby="auth-title">
|
||||||
|
<div class="card auth-card">
|
||||||
|
<div class="card-content">
|
||||||
|
{{if eq .Data.Mode "register"}}
|
||||||
|
<p class="kicker">Instance bootstrap</p>
|
||||||
|
<h1 id="auth-title">Create the admin account</h1>
|
||||||
|
<p class="muted-copy">The first user becomes the instance admin. Registration closes after this account is created.</p>
|
||||||
|
<form class="stack-form" action="/register" method="post">
|
||||||
|
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||||
|
<label><span>Username</span><input name="username" autocomplete="username" required></label>
|
||||||
|
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
|
||||||
|
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
|
||||||
|
<button class="button button-primary" type="submit">Create admin</button>
|
||||||
|
</form>
|
||||||
|
{{else if eq .Data.Mode "invite"}}
|
||||||
|
<p class="kicker">{{if .Data.IsReset}}Password reset{{else}}Invite{{end}}</p>
|
||||||
|
<h1 id="auth-title">{{if .Data.IsReset}}Choose a new password{{else}}Create your account{{end}}</h1>
|
||||||
|
{{if .Data.Email}}<p class="muted-copy">{{.Data.Email}}</p>{{end}}
|
||||||
|
<form class="stack-form" action="/invite/{{.Data.Token}}" method="post">
|
||||||
|
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||||
|
{{if not .Data.IsReset}}<label><span>Username</span><input name="username" autocomplete="username" required></label>{{end}}
|
||||||
|
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
|
||||||
|
<button class="button button-primary" type="submit">Accept invite</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<p class="kicker">Account</p>
|
||||||
|
<h1 id="auth-title">Sign in</h1>
|
||||||
|
<form class="stack-form" action="/login" method="post">
|
||||||
|
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||||
|
<input type="hidden" name="next" value="{{.Data.ReturnPath}}">
|
||||||
|
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
|
||||||
|
<label><span>Password</span><input type="password" name="password" autocomplete="current-password" required></label>
|
||||||
|
<button class="button button-primary" type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<p class="auth-alt"><a href="/">Back to upload</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
73
backend/templates/pages/dashboard.html
Normal file
73
backend/templates/pages/dashboard.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{{define "dashboard.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<section class="app-shell" aria-labelledby="dashboard-title">
|
||||||
|
<aside class="app-sidebar">
|
||||||
|
<a class="sidebar-link is-active" href="/app">Dashboard</a>
|
||||||
|
<a class="sidebar-link" href="/account/settings">Settings</a>
|
||||||
|
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
|
||||||
|
<form class="sidebar-logout" action="/logout" method="post">
|
||||||
|
<button class="button button-outline" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
<form class="collection-create" action="/app/collections" method="post">
|
||||||
|
<label>
|
||||||
|
<span>New collection</span>
|
||||||
|
<input name="name" placeholder="Projects">
|
||||||
|
</label>
|
||||||
|
<button class="button button-outline" type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="app-main">
|
||||||
|
<div class="admin-header">
|
||||||
|
<div>
|
||||||
|
<p class="kicker">Personal space</p>
|
||||||
|
<h1 id="dashboard-title">My files</h1>
|
||||||
|
<p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-primary" href="/">Upload files</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="collection-tabs">
|
||||||
|
<a class="button {{if not .Data.Selected}}button-primary{{else}}button-outline{{end}}" href="/app">All</a>
|
||||||
|
{{range .Data.Collections}}
|
||||||
|
<a class="button {{if eq $.Data.Selected .ID}}button-primary{{else}}button-outline{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card admin-table-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="table-header"><h2>Owned boxes</h2><p>Collections organize boxes. Shared links remain unlisted.</p></div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead><tr><th>Title</th><th>Collection</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Data.Boxes}}
|
||||||
|
<tr>
|
||||||
|
<td class="file-name">{{.Title}}</td>
|
||||||
|
<td>{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}}</td>
|
||||||
|
<td>{{.FileCount}}</td>
|
||||||
|
<td>{{.Size}}</td>
|
||||||
|
<td>{{.CreatedAt}}</td>
|
||||||
|
<td>{{.ExpiresAt}}</td>
|
||||||
|
<td class="table-actions">
|
||||||
|
<a class="button button-outline" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
|
||||||
|
<form action="/app/boxes/{{.ID}}/rename" method="post"><input class="compact-input" name="title" placeholder="Rename"><button class="button button-outline" type="submit">Save</button></form>
|
||||||
|
<form action="/app/boxes/{{.ID}}/move" method="post">
|
||||||
|
<select name="collection_id"><option value="">Unsorted</option>{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select>
|
||||||
|
<button class="button button-outline" type="submit">Move</button>
|
||||||
|
</form>
|
||||||
|
<form action="/app/boxes/{{.ID}}/delete" method="post"><button class="button button-danger" type="submit">Delete</button></form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7">You have no boxes yet.</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="drop-title">Drop files to upload</span>
|
<span class="drop-title">Drop files to upload</span>
|
||||||
<span class="drop-copy">or click to browse</span>
|
<span class="drop-copy">or click to browse</span>
|
||||||
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · Links expire in 7 days</span>
|
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
|
||||||
<input id="file-input" name="file" type="file" multiple>
|
<input id="file-input" name="file" type="file" multiple>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@
|
|||||||
Advanced options
|
Advanced options
|
||||||
</summary>
|
</summary>
|
||||||
<div class="option-grid">
|
<div class="option-grid">
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<label>
|
||||||
|
<span>Collection</span>
|
||||||
|
<select name="collection_id">
|
||||||
|
<option value="">Unsorted</option>
|
||||||
|
{{range .Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
<label>
|
<label>
|
||||||
<span>Expires in</span>
|
<span>Expires in</span>
|
||||||
<select name="max_days">
|
<select name="max_days">
|
||||||
|
|||||||
6
scripts/env/dev.env.example
vendored
6
scripts/env/dev.env.example
vendored
@@ -10,6 +10,12 @@ WARPBOX_CLEANUP_EVERY=1h
|
|||||||
WARPBOX_THUMBNAIL_ENABLED=true
|
WARPBOX_THUMBNAIL_ENABLED=true
|
||||||
WARPBOX_THUMBNAIL_EVERY=1m
|
WARPBOX_THUMBNAIL_EVERY=1m
|
||||||
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
||||||
|
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
|
||||||
|
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
|
||||||
|
WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
|
||||||
|
WARPBOX_USER_DAILY_UPLOAD_MB=8192
|
||||||
|
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
|
||||||
|
WARPBOX_USAGE_RETENTION_DAYS=30
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_WRITE_TIMEOUT=60s
|
||||||
WARPBOX_IDLE_TIMEOUT=120s
|
WARPBOX_IDLE_TIMEOUT=120s
|
||||||
|
|||||||
Reference in New Issue
Block a user