diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index 2b20ec2..8d10c4e 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -100,25 +100,51 @@ func TestBearerTokenUploadActsAsUser(t *testing.T) { t.Fatalf("OwnerID = %q, want %q", box.OwnerID, user.ID) } - // An invalid bearer token must not authenticate as the user. + // An invalid bearer token is an authentication failure, not an anonymous upload. badRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "x.txt", "x") badRequest.Header.Set("Accept", "application/json") badRequest.Header.Set("Authorization", "Bearer wbx_bogus.secret") badResponse := httptest.NewRecorder() app.Upload(badResponse, badRequest) - if badResponse.Code != http.StatusCreated { - t.Fatalf("anonymous fallback upload status = %d, body = %s", badResponse.Code, badResponse.Body.String()) + if badResponse.Code != http.StatusUnauthorized { + t.Fatalf("invalid token upload status = %d, body = %s", badResponse.Code, badResponse.Body.String()) } - var badPayload services.UploadResult - if err := json.Unmarshal(badResponse.Body.Bytes(), &badPayload); err != nil { - t.Fatalf("json.Unmarshal returned error: %v", err) +} + +func TestAnonymousUploadWithoutBearerStillWorks(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + response := httptest.NewRecorder() + app.Upload(response, multipartUploadRequest(t, "/api/v1/upload", "file", "anonymous.txt", "anonymous")) + if response.Code != http.StatusCreated { + t.Fatalf("anonymous upload status = %d, body = %s", response.Code, response.Body.String()) } - badBox, err := app.uploadService.GetBox(badPayload.BoxID) +} + +func TestDisabledUserBearerTokenCannotUpload(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123") if err != nil { - t.Fatalf("GetBox returned error: %v", err) + t.Fatalf("CreateBootstrapUser returned error: %v", err) } - if badBox.OwnerID != "" { - t.Fatalf("invalid token OwnerID = %q, want empty", badBox.OwnerID) + tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli") + if err != nil { + t.Fatalf("CreateAPIToken returned error: %v", err) + } + if err := app.authService.DisableUser(user.ID, true); err != nil { + t.Fatalf("DisableUser returned error: %v", err) + } + + request := multipartUploadRequest(t, "/api/v1/upload", "file", "blocked.txt", "blocked") + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext) + response := httptest.NewRecorder() + app.Upload(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("disabled bearer upload status = %d, body = %s", response.Code, response.Body.String()) } } diff --git a/backend/libs/handlers/auth.go b/backend/libs/handlers/auth.go index 6ea5fea..5650a7a 100644 --- a/backend/libs/handlers/auth.go +++ b/backend/libs/handlers/auth.go @@ -251,23 +251,32 @@ func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, pa } func (a *App) currentUser(r *http.Request) (services.User, bool) { + user, ok, _ := a.currentUserWithAuthError(r) + return user, ok +} + +func (a *App) currentUserWithAuthError(r *http.Request) (services.User, bool, error) { // Personal access tokens via Authorization: Bearer act as their owning user. // A bearer header is never set by browsers cross-site, so this path is not // subject to CSRF and intentionally bypasses the session cookie. if header := r.Header.Get("Authorization"); header != "" { if raw, ok := strings.CutPrefix(header, "Bearer "); ok { - if user, err := a.authService.UserForAPIToken(raw); err == nil { - return user, true + user, err := a.authService.UserForAPIToken(raw) + if err != nil { + return services.User{}, false, err } - return services.User{}, false + return user, true, nil } } cookie, err := r.Cookie(userSessionCookieName) if err != nil { - return services.User{}, false + return services.User{}, false, nil } user, _, err := a.authService.UserForSession(cookie.Value) - return user, err == nil + if err != nil { + return services.User{}, false, nil + } + return user, true, nil } func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) { diff --git a/backend/libs/handlers/static_test.go b/backend/libs/handlers/static_test.go index 3dde3cc..5a038e7 100644 --- a/backend/libs/handlers/static_test.go +++ b/backend/libs/handlers/static_test.go @@ -7,8 +7,8 @@ import ( func TestSetStaticCacheHeaders(t *testing.T) { tests := map[string]string{ - "/static/css/app.css": "public, max-age=86400", - "/static/js/app.js": "public, max-age=86400", + "/static/css/00-base.css": "public, max-age=86400", + "/static/js/00-utils.js": "public, max-age=86400", "/static/img/preview.webp": "public, max-age=31536000, immutable", "/static/fonts/ui.woff2": "public, max-age=31536000, immutable", "/static/videos/intro.mp4": "public, max-age=31536000, immutable", diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index b29ebec..80fd2e8 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -16,7 +16,11 @@ import ( ) func (a *App) Upload(w http.ResponseWriter, r *http.Request) { - user, loggedIn := a.currentUser(r) + user, loggedIn, authErr := a.currentUserWithAuthError(r) + if authErr != nil { + helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token") + return + } isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin settings, err := a.settingsService.UploadPolicy() if err != nil { diff --git a/backend/libs/services/storage.go b/backend/libs/services/storage.go index 5024752..ebd34a6 100644 --- a/backend/libs/services/storage.go +++ b/backend/libs/services/storage.go @@ -1,29 +1,18 @@ package services import ( - "bytes" "context" "encoding/json" - "encoding/xml" "fmt" "io" - "net" - "net/http" - "net/url" "os" "path" "path/filepath" "sort" - "strconv" "strings" "time" - "github.com/hirochachacha/go-smb2" - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" - "github.com/pkg/sftp" "go.etcd.io/bbolt" - "golang.org/x/crypto/ssh" ) var storageBackendsBucket = []byte("storage_backends") @@ -400,7 +389,7 @@ func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBac case StorageBackendSMB: return smbStorageBackend{cfg: cfg}, nil case StorageBackendWebDAV: - return webDAVStorageBackend{cfg: cfg, client: http.DefaultClient}, nil + return newWebDAVStorageBackend(cfg), nil default: return nil, fmt.Errorf("unsupported storage backend type %q", cfg.Type) } @@ -420,758 +409,6 @@ func (s *StorageService) localConfig() StorageBackendConfig { } } -type localStorageBackend struct { - id string - root string -} - -func (b localStorageBackend) ID() string { return b.id } -func (b localStorageBackend) Type() string { return StorageBackendLocal } - -func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error { - path, err := b.path(key) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer target.Close() - _, err = io.Copy(target, body) - return err -} - -func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) { - path, err := b.path(key) - if err != nil { - return StorageObject{}, err - } - source, err := os.Open(path) - if err != nil { - return StorageObject{}, err - } - stat, err := source.Stat() - if err != nil { - source.Close() - return StorageObject{}, err - } - return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil -} - -func (b localStorageBackend) Delete(_ context.Context, key string) error { - path, err := b.path(key) - if err != nil { - return err - } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error { - path, err := b.path(prefix) - if err != nil { - return err - } - if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func (b localStorageBackend) Usage(_ context.Context) (int64, error) { - var total int64 - err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - return nil - } - info, err := entry.Info() - if err != nil { - return err - } - total += info.Size() - return nil - }) - if os.IsNotExist(err) { - return 0, nil - } - return total, err -} - -func (b localStorageBackend) Test(ctx context.Context) error { - key := ".warpbox-storage-test-" + randomID(6) - if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { - return err - } - return b.Delete(ctx, key) -} - -func (b localStorageBackend) path(key string) (string, error) { - key = filepath.Clean(strings.TrimPrefix(key, "/")) - if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) { - return "", fmt.Errorf("invalid storage key") - } - path := filepath.Join(b.root, key) - root, err := filepath.Abs(b.root) - if err != nil { - return "", err - } - abs, err := filepath.Abs(path) - if err != nil { - return "", err - } - if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) { - return "", fmt.Errorf("invalid storage key") - } - return abs, nil -} - -type s3StorageBackend struct { - cfg StorageBackendConfig - client *minio.Client -} - -func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) { - endpoint := normalizeS3Endpoint(cfg.Endpoint) - client, err := minio.New(endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), - Secure: cfg.UseSSL, - Region: cfg.Region, - BucketLookup: s3BucketLookup(cfg.PathStyle), - }) - if err != nil { - return nil, err - } - return &s3StorageBackend{cfg: cfg, client: client}, nil -} - -func (b *s3StorageBackend) ID() string { return b.cfg.ID } -func (b *s3StorageBackend) Type() string { return StorageBackendS3 } - -func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { - opts := minio.PutObjectOptions{ContentType: contentType} - _, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts) - return err -} - -func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { - object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{}) - if err != nil { - return StorageObject{}, err - } - info, err := object.Stat() - if err != nil { - object.Close() - return StorageObject{}, err - } - return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil -} - -func (b *s3StorageBackend) Delete(ctx context.Context, key string) error { - return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{}) -} - -func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error { - prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/" - objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) - for object := range objects { - if object.Err != nil { - return object.Err - } - if err := b.Delete(ctx, object.Key); err != nil { - return err - } - } - return nil -} - -func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) { - var total int64 - for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) { - if object.Err != nil { - return 0, object.Err - } - total += object.Size - } - return total, nil -} - -func (b *s3StorageBackend) Test(ctx context.Context) error { - exists, err := b.client.BucketExists(ctx, b.cfg.Bucket) - if err != nil { - return err - } - if !exists { - return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket) - } - key := ".warpbox-storage-test-" + randomID(6) - if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil { - return err - } - return b.Delete(ctx, key) -} - -type sftpStorageBackend struct { - cfg StorageBackendConfig -} - -func (b sftpStorageBackend) ID() string { return b.cfg.ID } -func (b sftpStorageBackend) Type() string { return StorageBackendSFTP } - -func (b sftpStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error { - client, closer, err := b.client() - if err != nil { - return err - } - defer closer() - if err := ctx.Err(); err != nil { - return err - } - remotePath := b.remotePath(key) - if err := client.MkdirAll(path.Dir(remotePath)); err != nil { - return err - } - target, err := client.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY) - if err != nil { - return err - } - defer target.Close() - _, err = io.Copy(target, body) - return err -} - -func (b sftpStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { - client, closer, err := b.client() - if err != nil { - return StorageObject{}, err - } - if err := ctx.Err(); err != nil { - closer() - return StorageObject{}, err - } - remotePath := b.remotePath(key) - source, err := client.Open(remotePath) - if err != nil { - closer() - return StorageObject{}, err - } - stat, err := source.Stat() - if err != nil { - source.Close() - closer() - return StorageObject{}, err - } - return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil -} - -func (b sftpStorageBackend) Delete(ctx context.Context, key string) error { - client, closer, err := b.client() - if err != nil { - return err - } - defer closer() - if err := ctx.Err(); err != nil { - return err - } - if err := client.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func (b sftpStorageBackend) DeletePrefix(ctx context.Context, prefix string) error { - client, closer, err := b.client() - if err != nil { - return err - } - defer closer() - if err := ctx.Err(); err != nil { - return err - } - remotePath := b.remotePath(prefix) - if err := client.RemoveDirectory(remotePath); err == nil || os.IsNotExist(err) { - return nil - } - walker := client.Walk(remotePath) - paths := make([]string, 0) - for walker.Step() { - if walker.Err() != nil { - return walker.Err() - } - paths = append(paths, walker.Path()) - } - sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) - for _, item := range paths { - if err := client.Remove(item); err != nil { - _ = client.RemoveDirectory(item) - } - } - _ = client.RemoveDirectory(remotePath) - return nil -} - -func (b sftpStorageBackend) Usage(ctx context.Context) (int64, error) { - client, closer, err := b.client() - if err != nil { - return 0, err - } - defer closer() - if err := ctx.Err(); err != nil { - return 0, err - } - var total int64 - walker := client.Walk(cleanRemoteRoot(b.cfg.RemotePath)) - for walker.Step() { - if walker.Err() != nil { - return 0, walker.Err() - } - info := walker.Stat() - if info != nil && !info.IsDir() { - total += info.Size() - } - } - return total, nil -} - -func (b sftpStorageBackend) Test(ctx context.Context) error { - key := ".warpbox-storage-test-" + randomID(6) - if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { - return err - } - return b.Delete(ctx, key) -} - -func (b sftpStorageBackend) client() (*sftp.Client, func(), error) { - auth := make([]ssh.AuthMethod, 0, 2) - if b.cfg.PrivateKey != "" { - signer, err := ssh.ParsePrivateKey([]byte(b.cfg.PrivateKey)) - if err != nil { - return nil, nil, err - } - auth = append(auth, ssh.PublicKeys(signer)) - } - if b.cfg.Password != "" { - auth = append(auth, ssh.Password(b.cfg.Password)) - } - if len(auth) == 0 { - return nil, nil, fmt.Errorf("sftp password or private key is required") - } - hostKeyCallback, err := b.hostKeyCallback() - if err != nil { - return nil, nil, err - } - sshClient, err := ssh.Dial("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), &ssh.ClientConfig{ - User: b.cfg.Username, - Auth: auth, - HostKeyCallback: hostKeyCallback, - Timeout: 15 * time.Second, - }) - if err != nil { - return nil, nil, err - } - client, err := sftp.NewClient(sshClient) - if err != nil { - sshClient.Close() - return nil, nil, err - } - return client, func() { - client.Close() - sshClient.Close() - }, nil -} - -func (b sftpStorageBackend) hostKeyCallback() (ssh.HostKeyCallback, error) { - if strings.TrimSpace(b.cfg.HostKey) == "" { - return ssh.InsecureIgnoreHostKey(), nil - } - key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(b.cfg.HostKey))) - if err != nil { - return nil, fmt.Errorf("invalid sftp host public key: %w", err) - } - return ssh.FixedHostKey(key), nil -} - -func (b sftpStorageBackend) remotePath(key string) string { - return path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)) -} - -type joinedReadCloser struct { - io.ReadCloser - close func() -} - -func closeWith(source io.ReadCloser, close func()) io.ReadCloser { - return joinedReadCloser{ReadCloser: source, close: close} -} - -func (c joinedReadCloser) Close() error { - err := c.ReadCloser.Close() - c.close() - return err -} - -type smbStorageBackend struct { - cfg StorageBackendConfig -} - -func (b smbStorageBackend) ID() string { return b.cfg.ID } -func (b smbStorageBackend) Type() string { return StorageBackendSMB } - -func (b smbStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error { - share, closer, err := b.share() - if err != nil { - return err - } - defer closer() - if err := ctx.Err(); err != nil { - return err - } - remotePath := b.remotePath(key) - if err := share.MkdirAll(path.Dir(remotePath), 0o755); err != nil { - return err - } - target, err := share.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer target.Close() - _, err = io.Copy(target, body) - return err -} - -func (b smbStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { - share, closer, err := b.share() - if err != nil { - return StorageObject{}, err - } - if err := ctx.Err(); err != nil { - closer() - return StorageObject{}, err - } - source, err := share.Open(b.remotePath(key)) - if err != nil { - closer() - return StorageObject{}, err - } - stat, err := source.Stat() - if err != nil { - source.Close() - closer() - return StorageObject{}, err - } - return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil -} - -func (b smbStorageBackend) Delete(ctx context.Context, key string) error { - share, closer, err := b.share() - if err != nil { - return err - } - defer closer() - if err := ctx.Err(); err != nil { - return err - } - if err := share.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func (b smbStorageBackend) DeletePrefix(ctx context.Context, prefix string) error { - share, closer, err := b.share() - if err != nil { - return err - } - defer closer() - if err := ctx.Err(); err != nil { - return err - } - err = share.RemoveAll(b.remotePath(prefix)) - if err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -func (b smbStorageBackend) Usage(ctx context.Context) (int64, error) { - share, closer, err := b.share() - if err != nil { - return 0, err - } - defer closer() - if err := ctx.Err(); err != nil { - return 0, err - } - return smbUsage(share, cleanRemoteRoot(b.cfg.RemotePath)) -} - -func (b smbStorageBackend) Test(ctx context.Context) error { - key := ".warpbox-storage-test-" + randomID(6) - if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { - return err - } - return b.Delete(ctx, key) -} - -func (b smbStorageBackend) share() (*smb2.Share, func(), error) { - conn, err := net.DialTimeout("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), 15*time.Second) - if err != nil { - return nil, nil, err - } - dialer := &smb2.Dialer{ - Initiator: &smb2.NTLMInitiator{ - User: b.cfg.Username, - Password: b.cfg.Password, - Domain: b.cfg.Domain, - }, - } - session, err := dialer.Dial(conn) - if err != nil { - conn.Close() - return nil, nil, err - } - share, err := session.Mount(b.cfg.Share) - if err != nil { - session.Logoff() - conn.Close() - return nil, nil, err - } - return share, func() { - share.Umount() - session.Logoff() - conn.Close() - }, nil -} - -func (b smbStorageBackend) remotePath(key string) string { - return strings.TrimPrefix(path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)), "/") -} - -func smbUsage(share *smb2.Share, root string) (int64, error) { - root = strings.TrimPrefix(root, "/") - entries, err := share.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return 0, nil - } - return 0, err - } - var total int64 - for _, entry := range entries { - item := path.Join(root, entry.Name()) - if entry.IsDir() { - size, err := smbUsage(share, item) - if err != nil { - return 0, err - } - total += size - continue - } - total += entry.Size() - } - return total, nil -} - -type webDAVStorageBackend struct { - cfg StorageBackendConfig - client *http.Client -} - -func (b webDAVStorageBackend) ID() string { return b.cfg.ID } -func (b webDAVStorageBackend) Type() string { return StorageBackendWebDAV } - -func (b webDAVStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, contentType string) error { - if err := b.mkcolParents(ctx, key); err != nil { - return err - } - request, err := b.request(ctx, http.MethodPut, key, body) - if err != nil { - return err - } - if contentType != "" { - request.Header.Set("Content-Type", contentType) - } - response, err := b.client.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("webdav put failed: %s", response.Status) - } - return nil -} - -func (b webDAVStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { - request, err := b.request(ctx, http.MethodGet, key, nil) - if err != nil { - return StorageObject{}, err - } - response, err := b.client.Do(request) - if err != nil { - return StorageObject{}, err - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - response.Body.Close() - return StorageObject{}, fmt.Errorf("webdav get failed: %s", response.Status) - } - modTime, _ := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified")) - return StorageObject{Key: key, Size: response.ContentLength, ContentType: response.Header.Get("Content-Type"), ModTime: modTime, Body: response.Body}, nil -} - -func (b webDAVStorageBackend) Delete(ctx context.Context, key string) error { - return b.deletePath(ctx, key) -} - -func (b webDAVStorageBackend) DeletePrefix(ctx context.Context, prefix string) error { - return b.deletePath(ctx, strings.TrimSuffix(prefix, "/")+"/") -} - -func (b webDAVStorageBackend) Usage(ctx context.Context) (int64, error) { - request, err := b.request(ctx, "PROPFIND", "", nil) - if err != nil { - return 0, err - } - request.Header.Set("Depth", "infinity") - request.Header.Set("Content-Type", "application/xml") - response, err := b.client.Do(request) - if err != nil { - return 0, err - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return 0, fmt.Errorf("webdav usage failed: %s", response.Status) - } - var multi webDAVMultiStatus - if err := xml.NewDecoder(response.Body).Decode(&multi); err != nil { - return 0, err - } - var total int64 - for _, item := range multi.Responses { - if item.PropStat.Prop.ResourceType.Collection != nil { - continue - } - total += item.PropStat.Prop.ContentLength - } - return total, nil -} - -func (b webDAVStorageBackend) Test(ctx context.Context) error { - key := ".warpbox-storage-test-" + randomID(6) - if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { - return err - } - return b.Delete(ctx, key) -} - -func (b webDAVStorageBackend) deletePath(ctx context.Context, key string) error { - request, err := b.request(ctx, http.MethodDelete, key, nil) - if err != nil { - return err - } - response, err := b.client.Do(request) - if err != nil { - return err - } - defer response.Body.Close() - if response.StatusCode == http.StatusNotFound { - return nil - } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("webdav delete failed: %s", response.Status) - } - return nil -} - -func (b webDAVStorageBackend) mkcolParents(ctx context.Context, key string) error { - dir := path.Dir(cleanObjectKey(key)) - if dir == "." || dir == "/" { - return nil - } - parts := strings.Split(strings.Trim(dir, "/"), "/") - current := "" - for _, part := range parts { - current = path.Join(current, part) - request, err := b.request(ctx, "MKCOL", strings.TrimSuffix(current, "/")+"/", nil) - if err != nil { - return err - } - response, err := b.client.Do(request) - if err != nil { - return err - } - response.Body.Close() - if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed && response.StatusCode != http.StatusConflict { - return fmt.Errorf("webdav mkcol failed: %s", response.Status) - } - } - return nil -} - -func (b webDAVStorageBackend) request(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) { - endpoint := strings.TrimRight(b.cfg.Endpoint, "/") - if endpoint == "" { - return nil, fmt.Errorf("webdav url is required") - } - remote := path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)) - if strings.HasSuffix(key, "/") && !strings.HasSuffix(remote, "/") { - remote += "/" - } - target := endpoint + "/" + strings.TrimLeft(remote, "/") - request, err := http.NewRequestWithContext(ctx, method, target, body) - if err != nil { - return nil, err - } - if b.cfg.Username != "" || b.cfg.Password != "" { - request.SetBasicAuth(b.cfg.Username, b.cfg.Password) - } - return request, nil -} - -type webDAVMultiStatus struct { - Responses []webDAVResponse `xml:"response"` -} - -type webDAVResponse struct { - PropStat webDAVPropStat `xml:"propstat"` -} - -type webDAVPropStat struct { - Prop webDAVProp `xml:"prop"` -} - -type webDAVProp struct { - ContentLength int64 `xml:"getcontentlength"` - ResourceType webDAVResourceType `xml:"resourcetype"` -} - -type webDAVResourceType struct { - Collection *struct{} `xml:"collection"` -} - -func s3BucketLookup(pathStyle bool) minio.BucketLookupType { - if pathStyle { - return minio.BucketLookupPath - } - return minio.BucketLookupAuto -} - -func normalizeS3Endpoint(endpoint string) string { - endpoint = strings.TrimSpace(endpoint) - if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" { - return parsed.Host - } - return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://") -} - func normalizeStorageProvider(provider string) string { switch strings.TrimSpace(provider) { case StorageProviderContabo: diff --git a/backend/libs/services/storage_local.go b/backend/libs/services/storage_local.go new file mode 100644 index 0000000..f9364aa --- /dev/null +++ b/backend/libs/services/storage_local.go @@ -0,0 +1,124 @@ +package services + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type localStorageBackend struct { + id string + root string +} + +func (b localStorageBackend) ID() string { return b.id } +func (b localStorageBackend) Type() string { return StorageBackendLocal } + +func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error { + path, err := b.path(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer target.Close() + _, err = io.Copy(target, body) + return err +} + +func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) { + path, err := b.path(key) + if err != nil { + return StorageObject{}, err + } + source, err := os.Open(path) + if err != nil { + return StorageObject{}, err + } + stat, err := source.Stat() + if err != nil { + source.Close() + return StorageObject{}, err + } + return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil +} + +func (b localStorageBackend) Delete(_ context.Context, key string) error { + path, err := b.path(key) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error { + path, err := b.path(prefix) + if err != nil { + return err + } + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (b localStorageBackend) Usage(_ context.Context) (int64, error) { + var total int64 + err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + total += info.Size() + return nil + }) + if os.IsNotExist(err) { + return 0, nil + } + return total, err +} + +func (b localStorageBackend) Test(ctx context.Context) error { + key := ".warpbox-storage-test-" + randomID(6) + if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { + return err + } + return b.Delete(ctx, key) +} + +func (b localStorageBackend) path(key string) (string, error) { + key = filepath.Clean(strings.TrimPrefix(key, "/")) + if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) { + return "", fmt.Errorf("invalid storage key") + } + path := filepath.Join(b.root, key) + root, err := filepath.Abs(b.root) + if err != nil { + return "", err + } + abs, err := filepath.Abs(path) + if err != nil { + return "", err + } + if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid storage key") + } + return abs, nil +} diff --git a/backend/libs/services/storage_readcloser.go b/backend/libs/services/storage_readcloser.go new file mode 100644 index 0000000..922b526 --- /dev/null +++ b/backend/libs/services/storage_readcloser.go @@ -0,0 +1,18 @@ +package services + +import "io" + +type joinedReadCloser struct { + io.ReadCloser + close func() +} + +func closeWith(source io.ReadCloser, close func()) io.ReadCloser { + return joinedReadCloser{ReadCloser: source, close: close} +} + +func (c joinedReadCloser) Close() error { + err := c.ReadCloser.Close() + c.close() + return err +} diff --git a/backend/libs/services/storage_s3.go b/backend/libs/services/storage_s3.go new file mode 100644 index 0000000..cad07d8 --- /dev/null +++ b/backend/libs/services/storage_s3.go @@ -0,0 +1,113 @@ +package services + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type s3StorageBackend struct { + cfg StorageBackendConfig + client *minio.Client +} + +func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) { + endpoint := normalizeS3Endpoint(cfg.Endpoint) + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.UseSSL, + Region: cfg.Region, + BucketLookup: s3BucketLookup(cfg.PathStyle), + }) + if err != nil { + return nil, err + } + return &s3StorageBackend{cfg: cfg, client: client}, nil +} + +func (b *s3StorageBackend) ID() string { return b.cfg.ID } +func (b *s3StorageBackend) Type() string { return StorageBackendS3 } + +func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { + opts := minio.PutObjectOptions{ContentType: contentType} + _, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts) + return err +} + +func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { + object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{}) + if err != nil { + return StorageObject{}, err + } + info, err := object.Stat() + if err != nil { + object.Close() + return StorageObject{}, err + } + return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil +} + +func (b *s3StorageBackend) Delete(ctx context.Context, key string) error { + return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{}) +} + +func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error { + prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/" + objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) + for object := range objects { + if object.Err != nil { + return object.Err + } + if err := b.Delete(ctx, object.Key); err != nil { + return err + } + } + return nil +} + +func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) { + var total int64 + for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) { + if object.Err != nil { + return 0, object.Err + } + total += object.Size + } + return total, nil +} + +func (b *s3StorageBackend) Test(ctx context.Context) error { + exists, err := b.client.BucketExists(ctx, b.cfg.Bucket) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket) + } + key := ".warpbox-storage-test-" + randomID(6) + if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil { + return err + } + return b.Delete(ctx, key) +} + +func s3BucketLookup(pathStyle bool) minio.BucketLookupType { + if pathStyle { + return minio.BucketLookupPath + } + return minio.BucketLookupAuto +} + +func normalizeS3Endpoint(endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" { + return parsed.Host + } + return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://") +} diff --git a/backend/libs/services/storage_sftp.go b/backend/libs/services/storage_sftp.go new file mode 100644 index 0000000..ef75af9 --- /dev/null +++ b/backend/libs/services/storage_sftp.go @@ -0,0 +1,200 @@ +package services + +import ( + "context" + "fmt" + "io" + "os" + "path" + "sort" + "strconv" + "strings" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type sftpStorageBackend struct { + cfg StorageBackendConfig +} + +func (b sftpStorageBackend) ID() string { return b.cfg.ID } +func (b sftpStorageBackend) Type() string { return StorageBackendSFTP } + +func (b sftpStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error { + client, closer, err := b.client() + if err != nil { + return err + } + defer closer() + if err := ctx.Err(); err != nil { + return err + } + remotePath := b.remotePath(key) + if err := client.MkdirAll(path.Dir(remotePath)); err != nil { + return err + } + target, err := client.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY) + if err != nil { + return err + } + defer target.Close() + _, err = io.Copy(target, body) + return err +} + +func (b sftpStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { + client, closer, err := b.client() + if err != nil { + return StorageObject{}, err + } + if err := ctx.Err(); err != nil { + closer() + return StorageObject{}, err + } + remotePath := b.remotePath(key) + source, err := client.Open(remotePath) + if err != nil { + closer() + return StorageObject{}, err + } + stat, err := source.Stat() + if err != nil { + source.Close() + closer() + return StorageObject{}, err + } + return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil +} + +func (b sftpStorageBackend) Delete(ctx context.Context, key string) error { + client, closer, err := b.client() + if err != nil { + return err + } + defer closer() + if err := ctx.Err(); err != nil { + return err + } + if err := client.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (b sftpStorageBackend) DeletePrefix(ctx context.Context, prefix string) error { + client, closer, err := b.client() + if err != nil { + return err + } + defer closer() + if err := ctx.Err(); err != nil { + return err + } + remotePath := b.remotePath(prefix) + if err := client.RemoveDirectory(remotePath); err == nil || os.IsNotExist(err) { + return nil + } + walker := client.Walk(remotePath) + paths := make([]string, 0) + for walker.Step() { + if walker.Err() != nil { + return walker.Err() + } + paths = append(paths, walker.Path()) + } + sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + for _, item := range paths { + if err := client.Remove(item); err != nil { + _ = client.RemoveDirectory(item) + } + } + _ = client.RemoveDirectory(remotePath) + return nil +} + +func (b sftpStorageBackend) Usage(ctx context.Context) (int64, error) { + client, closer, err := b.client() + if err != nil { + return 0, err + } + defer closer() + if err := ctx.Err(); err != nil { + return 0, err + } + var total int64 + walker := client.Walk(cleanRemoteRoot(b.cfg.RemotePath)) + for walker.Step() { + if walker.Err() != nil { + return 0, walker.Err() + } + info := walker.Stat() + if info != nil && !info.IsDir() { + total += info.Size() + } + } + return total, nil +} + +func (b sftpStorageBackend) Test(ctx context.Context) error { + key := ".warpbox-storage-test-" + randomID(6) + if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { + return err + } + return b.Delete(ctx, key) +} + +func (b sftpStorageBackend) client() (*sftp.Client, func(), error) { + auth := make([]ssh.AuthMethod, 0, 2) + if b.cfg.PrivateKey != "" { + signer, err := ssh.ParsePrivateKey([]byte(b.cfg.PrivateKey)) + if err != nil { + return nil, nil, err + } + auth = append(auth, ssh.PublicKeys(signer)) + } + if b.cfg.Password != "" { + auth = append(auth, ssh.Password(b.cfg.Password)) + } + if len(auth) == 0 { + return nil, nil, fmt.Errorf("sftp password or private key is required") + } + hostKeyCallback, err := b.hostKeyCallback() + if err != nil { + return nil, nil, err + } + sshClient, err := ssh.Dial("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), &ssh.ClientConfig{ + User: b.cfg.Username, + Auth: auth, + HostKeyCallback: hostKeyCallback, + Timeout: 15 * time.Second, + }) + if err != nil { + return nil, nil, err + } + client, err := sftp.NewClient(sshClient) + if err != nil { + sshClient.Close() + return nil, nil, err + } + return client, func() { + client.Close() + sshClient.Close() + }, nil +} + +func (b sftpStorageBackend) hostKeyCallback() (ssh.HostKeyCallback, error) { + if strings.TrimSpace(b.cfg.HostKey) == "" { + return ssh.InsecureIgnoreHostKey(), nil + } + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(b.cfg.HostKey))) + if err != nil { + return nil, fmt.Errorf("invalid sftp host public key: %w", err) + } + return ssh.FixedHostKey(key), nil +} + +func (b sftpStorageBackend) remotePath(key string) string { + return path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)) +} diff --git a/backend/libs/services/storage_smb.go b/backend/libs/services/storage_smb.go new file mode 100644 index 0000000..ec0ef8e --- /dev/null +++ b/backend/libs/services/storage_smb.go @@ -0,0 +1,176 @@ +package services + +import ( + "context" + "io" + "net" + "os" + "path" + "strconv" + "strings" + "time" + + "github.com/hirochachacha/go-smb2" +) + +type smbStorageBackend struct { + cfg StorageBackendConfig +} + +func (b smbStorageBackend) ID() string { return b.cfg.ID } +func (b smbStorageBackend) Type() string { return StorageBackendSMB } + +func (b smbStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error { + share, closer, err := b.share() + if err != nil { + return err + } + defer closer() + if err := ctx.Err(); err != nil { + return err + } + remotePath := b.remotePath(key) + if err := share.MkdirAll(path.Dir(remotePath), 0o755); err != nil { + return err + } + target, err := share.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer target.Close() + _, err = io.Copy(target, body) + return err +} + +func (b smbStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { + share, closer, err := b.share() + if err != nil { + return StorageObject{}, err + } + if err := ctx.Err(); err != nil { + closer() + return StorageObject{}, err + } + source, err := share.Open(b.remotePath(key)) + if err != nil { + closer() + return StorageObject{}, err + } + stat, err := source.Stat() + if err != nil { + source.Close() + closer() + return StorageObject{}, err + } + return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil +} + +func (b smbStorageBackend) Delete(ctx context.Context, key string) error { + share, closer, err := b.share() + if err != nil { + return err + } + defer closer() + if err := ctx.Err(); err != nil { + return err + } + if err := share.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (b smbStorageBackend) DeletePrefix(ctx context.Context, prefix string) error { + share, closer, err := b.share() + if err != nil { + return err + } + defer closer() + if err := ctx.Err(); err != nil { + return err + } + err = share.RemoveAll(b.remotePath(prefix)) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func (b smbStorageBackend) Usage(ctx context.Context) (int64, error) { + share, closer, err := b.share() + if err != nil { + return 0, err + } + defer closer() + if err := ctx.Err(); err != nil { + return 0, err + } + return smbUsage(share, cleanRemoteRoot(b.cfg.RemotePath)) +} + +func (b smbStorageBackend) Test(ctx context.Context) error { + key := ".warpbox-storage-test-" + randomID(6) + if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { + return err + } + return b.Delete(ctx, key) +} + +func (b smbStorageBackend) share() (*smb2.Share, func(), error) { + conn, err := net.DialTimeout("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), 15*time.Second) + if err != nil { + return nil, nil, err + } + dialer := &smb2.Dialer{ + Initiator: &smb2.NTLMInitiator{ + User: b.cfg.Username, + Password: b.cfg.Password, + Domain: b.cfg.Domain, + }, + } + session, err := dialer.Dial(conn) + if err != nil { + conn.Close() + return nil, nil, err + } + share, err := session.Mount(b.cfg.Share) + if err != nil { + session.Logoff() + conn.Close() + return nil, nil, err + } + return share, func() { + share.Umount() + session.Logoff() + conn.Close() + }, nil +} + +func (b smbStorageBackend) remotePath(key string) string { + return strings.TrimPrefix(path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)), "/") +} + +func smbUsage(share *smb2.Share, root string) (int64, error) { + root = strings.TrimPrefix(root, "/") + entries, err := share.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + var total int64 + for _, entry := range entries { + item := path.Join(root, entry.Name()) + if entry.IsDir() { + size, err := smbUsage(share, item) + if err != nil { + return 0, err + } + total += size + continue + } + total += entry.Size() + } + return total, nil +} diff --git a/backend/libs/services/storage_webdav.go b/backend/libs/services/storage_webdav.go new file mode 100644 index 0000000..64851db --- /dev/null +++ b/backend/libs/services/storage_webdav.go @@ -0,0 +1,193 @@ +package services + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" +) + +type webDAVStorageBackend struct { + cfg StorageBackendConfig + client *http.Client +} + +func (b webDAVStorageBackend) ID() string { return b.cfg.ID } +func (b webDAVStorageBackend) Type() string { return StorageBackendWebDAV } + +func (b webDAVStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, contentType string) error { + if err := b.mkcolParents(ctx, key); err != nil { + return err + } + request, err := b.request(ctx, http.MethodPut, key, body) + if err != nil { + return err + } + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + response, err := b.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("webdav put failed: %s", response.Status) + } + return nil +} + +func (b webDAVStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) { + request, err := b.request(ctx, http.MethodGet, key, nil) + if err != nil { + return StorageObject{}, err + } + response, err := b.client.Do(request) + if err != nil { + return StorageObject{}, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + response.Body.Close() + return StorageObject{}, fmt.Errorf("webdav get failed: %s", response.Status) + } + modTime, _ := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified")) + return StorageObject{Key: key, Size: response.ContentLength, ContentType: response.Header.Get("Content-Type"), ModTime: modTime, Body: response.Body}, nil +} + +func (b webDAVStorageBackend) Delete(ctx context.Context, key string) error { + return b.deletePath(ctx, key) +} + +func (b webDAVStorageBackend) DeletePrefix(ctx context.Context, prefix string) error { + return b.deletePath(ctx, strings.TrimSuffix(prefix, "/")+"/") +} + +func (b webDAVStorageBackend) Usage(ctx context.Context) (int64, error) { + request, err := b.request(ctx, "PROPFIND", "", nil) + if err != nil { + return 0, err + } + request.Header.Set("Depth", "infinity") + request.Header.Set("Content-Type", "application/xml") + response, err := b.client.Do(request) + if err != nil { + return 0, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return 0, fmt.Errorf("webdav usage failed: %s", response.Status) + } + var multi webDAVMultiStatus + if err := xml.NewDecoder(response.Body).Decode(&multi); err != nil { + return 0, err + } + var total int64 + for _, item := range multi.Responses { + if item.PropStat.Prop.ResourceType.Collection != nil { + continue + } + total += item.PropStat.Prop.ContentLength + } + return total, nil +} + +func (b webDAVStorageBackend) Test(ctx context.Context) error { + key := ".warpbox-storage-test-" + randomID(6) + if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil { + return err + } + return b.Delete(ctx, key) +} + +func (b webDAVStorageBackend) deletePath(ctx context.Context, key string) error { + request, err := b.request(ctx, http.MethodDelete, key, nil) + if err != nil { + return err + } + response, err := b.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode == http.StatusNotFound { + return nil + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("webdav delete failed: %s", response.Status) + } + return nil +} + +func (b webDAVStorageBackend) mkcolParents(ctx context.Context, key string) error { + dir := path.Dir(cleanObjectKey(key)) + if dir == "." || dir == "/" { + return nil + } + parts := strings.Split(strings.Trim(dir, "/"), "/") + current := "" + for _, part := range parts { + current = path.Join(current, part) + request, err := b.request(ctx, "MKCOL", strings.TrimSuffix(current, "/")+"/", nil) + if err != nil { + return err + } + response, err := b.client.Do(request) + if err != nil { + return err + } + response.Body.Close() + if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed && response.StatusCode != http.StatusConflict { + return fmt.Errorf("webdav mkcol failed: %s", response.Status) + } + } + return nil +} + +func (b webDAVStorageBackend) request(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) { + endpoint := strings.TrimRight(b.cfg.Endpoint, "/") + if endpoint == "" { + return nil, fmt.Errorf("webdav url is required") + } + remote := path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)) + if strings.HasSuffix(key, "/") && !strings.HasSuffix(remote, "/") { + remote += "/" + } + target := endpoint + "/" + strings.TrimLeft(remote, "/") + request, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return nil, err + } + if b.cfg.Username != "" || b.cfg.Password != "" { + request.SetBasicAuth(b.cfg.Username, b.cfg.Password) + } + return request, nil +} + +type webDAVMultiStatus struct { + Responses []webDAVResponse `xml:"response"` +} + +type webDAVResponse struct { + PropStat webDAVPropStat `xml:"propstat"` +} + +type webDAVPropStat struct { + Prop webDAVProp `xml:"prop"` +} + +type webDAVProp struct { + ContentLength int64 `xml:"getcontentlength"` + ResourceType webDAVResourceType `xml:"resourcetype"` +} + +type webDAVResourceType struct { + Collection *struct{} `xml:"collection"` +} + +func newWebDAVStorageBackend(cfg StorageBackendConfig) webDAVStorageBackend { + return webDAVStorageBackend{cfg: cfg, client: http.DefaultClient} +} diff --git a/backend/static/css/00-base.css b/backend/static/css/00-base.css new file mode 100644 index 0000000..448e1f7 --- /dev/null +++ b/backend/static/css/00-base.css @@ -0,0 +1,431 @@ +:root { + color-scheme: dark; + --background: #09090b; + --foreground: #fafafa; + --card: #18181b; + --card-foreground: #fafafa; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --accent: #27272a; + --accent-foreground: #fafafa; + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --primary: #f4f4f5; + --primary-foreground: #18181b; + --ring: #71717a; + --success: #86efac; + --radius: 0.625rem; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.45); +} + +* { + box-sizing: border-box; +} + +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--background); + color: var(--foreground); +} + +body { + min-height: 100vh; + margin: 0; + display: flex; + flex-direction: column; + background: + radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem), + linear-gradient(180deg, #09090b 0%, #0f0f12 100%); +} + +a { + color: inherit; +} + +svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.skip-link { + position: absolute; + left: 1rem; + top: -4rem; + z-index: 10; + padding: 0.75rem 1rem; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); +} + +.skip-link:focus { + top: 1rem; +} + +.site-header { + position: sticky; + top: 0; + z-index: 20; + border-bottom: 1px solid var(--border); + background: rgba(9, 9, 11, 0.84); + backdrop-filter: blur(14px); +} + +.nav { + width: min(72rem, calc(100% - 2rem)); + min-height: 3.5rem; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.brand, +.nav-links, +.footer-links, +.inline-form { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.inline-form { + margin: 0; +} + +.brand { + font-weight: 650; + text-decoration: none; +} + +.brand-mark { + width: 1.75rem; + height: 1.75rem; + display: grid; + place-items: center; + border-radius: calc(var(--radius) - 0.125rem); + background: var(--primary); + color: var(--primary-foreground); + font-size: 0.85rem; + font-weight: 800; +} + +main { + flex: 1; +} + + +h1 { + margin: 0; + color: var(--foreground); + font-size: 2rem; + line-height: 1.12; + font-weight: 650; + letter-spacing: 0; +} + +.file-name { + display: block; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hero-copy p, +.download-subtitle, +.panel-header p { + margin: 0.55rem 0 0; + color: var(--muted-foreground); + font-size: 0.95rem; + line-height: 1.5; +} + +.card { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 94%, transparent); + box-shadow: var(--shadow); +} + +.card-content { + 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; +} + +.checkbox-field { + display: flex; + align-items: center; + gap: 0.55rem; +} + +.checkbox-field input { + width: 1rem; + min-height: 1rem; +} + +.checkbox-field span { + margin: 0; + color: var(--muted-foreground); +} + +label span { + display: block; + margin-bottom: 0.4rem; + color: var(--foreground); + font-size: 0.8rem; + font-weight: 600; +} + +input, +select, +button { + font: inherit; +} + +input, +select { + width: 100%; + min-height: 2.25rem; + border: 1px solid var(--input); + border-radius: calc(var(--radius) - 0.125rem); + padding: 0.45rem 0.7rem; + background: var(--background); + color: var(--foreground); +} + +input::placeholder { + color: var(--muted-foreground); +} + +input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.form-footer, +.result-header { + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.form-footer p, +#result-meta { + margin: 0; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +.button, +button { + min-height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 0.125rem); + padding: 0.45rem 0.85rem; + color: var(--foreground); + background: transparent; + font: inherit; + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + text-decoration: none; + cursor: pointer; +} + +.button-primary { + background: var(--primary); + color: var(--primary-foreground); +} + +.button-primary:hover { + background: #e4e4e7; +} + +.button-outline { + border-color: var(--border); + background: var(--background); +} + +.button-outline:hover, +.button-ghost:hover { + background: var(--accent); +} + +.button-danger { + border-color: rgba(248, 113, 113, 0.28); + background: rgba(127, 29, 29, 0.3); + color: #fecaca; +} + +.button-danger:hover { + background: rgba(127, 29, 29, 0.55); +} + +.button-wide { + width: 100%; + min-height: 2.75rem; + margin-top: 1.25rem; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + color: var(--muted-foreground); +} + +pre { + overflow-x: auto; + margin: 0.8rem 0 0; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--background); + padding: 0.9rem; + text-align: left; +} + +pre code { + display: block; + margin: 0; + overflow: visible; + white-space: pre; +} + + +.badge { + display: inline-flex; + align-items: center; + min-height: 1.5rem; + border-radius: 999px; + background: var(--muted); + color: var(--muted-foreground); + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.site-footer { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 1rem 0; + display: flex; + justify-content: space-between; + gap: 1rem; + color: var(--muted-foreground); + font-size: 0.78rem; +} + +.footer-links a { + text-decoration: none; +} + +.form-error { + margin: 1rem 0 0; + color: #fecaca; + font-size: 0.9rem; +} + +.button-sm { + min-height: 1.85rem; + padding: 0.3rem 0.65rem; + font-size: 0.8rem; +} + +/* Badge variants */ +.badge-active { + background: rgba(134, 239, 172, 0.12); + color: #86efac; +} + +.badge-disabled { + background: rgba(252, 165, 165, 0.1); + color: #fca5a5; +} + +.badge-expired { + opacity: 0.55; +} + +/* Nav username indicator in header */ +.nav-username { + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/backend/static/css/10-layout.css b/backend/static/css/10-layout.css new file mode 100644 index 0000000..8516ab0 --- /dev/null +++ b/backend/static/css/10-layout.css @@ -0,0 +1,281 @@ +.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 { + display: flex; + align-items: center; + gap: 0.55rem; + 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; + gap: 1.5rem; +} + +.settings-form-narrow { + grid-template-columns: minmax(0, 1fr); + gap: 0.9rem; +} + +.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; +} + + +/* Tab navigation */ +.tabs-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.tab-list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; +} + +.tab { + display: inline-flex; + align-items: center; + height: 2rem; + padding: 0 0.75rem; + border-radius: 999px; + border: 1px solid transparent; + color: var(--muted-foreground); + font-size: 0.84rem; + font-weight: 500; + text-decoration: none; + transition: background 120ms, color 120ms, border-color 120ms; +} + +.tab:hover { + background: var(--muted); + color: var(--foreground); +} + +.tab.is-active { + border-color: var(--border); + background: var(--muted); + color: var(--foreground); + font-weight: 650; +} + +/* Sidebar structure */ +.sidebar-sep { + height: 1px; + border: 0; + background: var(--border); + margin: 0.5rem 0; +} + +.sidebar-nav { + display: grid; + gap: 0.25rem; +} + +/* Collection create dropdown */ +.new-collection-drop { + position: relative; + flex-shrink: 0; +} + +.new-collection-drop > summary { + list-style: none; + cursor: pointer; +} + +.new-collection-drop > summary::-webkit-details-marker { display: none; } + +.new-collection-body { + position: absolute; + right: 0; + top: calc(100% + 0.5rem); + z-index: 10; + width: 15rem; + padding: 1rem; + background: color-mix(in srgb, var(--card) 97%, #000); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + display: grid; + gap: 0.65rem; +} + +.new-collection-body label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +/* Copyable URL field */ +.copy-field { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; +} + +.copy-field input { + flex: 1; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8rem; + color: var(--muted-foreground); +} + +/* Settings sections */ +.settings-section { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.settings-section-title { + grid-column: 1 / -1; + margin: 0; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--border); + font-size: 0.875rem; + font-weight: 650; + color: var(--foreground); +} + +.settings-section .checkbox-field { + grid-column: 1 / -1; +} + +.settings-section label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +/* Quota form in admin users table */ +.quota-form { + display: flex; + gap: 0.4rem; + align-items: center; + margin: 0; +} + +.quota-form input { + width: 6.5rem; + min-width: 0; +} diff --git a/backend/static/css/20-upload.css b/backend/static/css/20-upload.css new file mode 100644 index 0000000..690d3a4 --- /dev/null +++ b/backend/static/css/20-upload.css @@ -0,0 +1,302 @@ +.upload-view { + width: min(48rem, calc(100% - 2rem)); + min-height: calc(100vh - 7.25rem); + margin: 0 auto; + padding: 2.5rem 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1.5rem; +} + +.hero-copy { + text-align: center; +} + + +.drop-zone { + min-height: 19rem; + display: grid; + place-items: center; + align-content: center; + gap: 0.65rem; + padding: 2rem; + border: 2px dashed var(--border); + border-radius: var(--radius); + background: rgba(39, 39, 42, 0.42); + text-align: center; + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease; +} + +.drop-zone:hover, +.drop-zone.is-dragging { + border-color: var(--primary); + background: rgba(39, 39, 42, 0.68); +} + +.drop-zone input { + position: absolute; + inline-size: 1px; + block-size: 1px; + opacity: 0; + pointer-events: none; +} + +.drop-icon { + width: 2.75rem; + height: 2.75rem; + display: grid; + place-items: center; + color: var(--muted-foreground); +} + +.drop-icon svg { + width: 2.5rem; + height: 2.5rem; +} + +.drop-title { + font-size: 1rem; + font-weight: 650; +} + +.drop-copy, +.drop-meta { + color: var(--muted-foreground); + font-size: 0.9rem; +} + +.drop-meta { + margin-top: 0.75rem; + font-size: 0.78rem; +} + +.advanced-options { + margin-top: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(39, 39, 42, 0.28); + padding: 0.75rem 0.9rem; +} + +.advanced-options summary { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--foreground); + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; +} + +.option-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.9rem; + margin-top: 1rem; +} + + +.form-footer, +.result-header { + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.form-footer p, +#result-meta { + margin: 0; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +.button, +button { + min-height: 2.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 0.125rem); + padding: 0.45rem 0.85rem; + color: var(--foreground); + background: transparent; + font: inherit; + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + text-decoration: none; + cursor: pointer; +} + +.button-primary { + background: var(--primary); + color: var(--primary-foreground); +} + +.button-primary:hover { + background: #e4e4e7; +} + +.button-outline { + border-color: var(--border); + background: var(--background); +} + +.button-outline:hover, +.button-ghost:hover { + background: var(--accent); +} + +.button-danger { + border-color: rgba(248, 113, 113, 0.28); + background: rgba(127, 29, 29, 0.3); + color: #fecaca; +} + +.button-danger:hover { + background: rgba(127, 29, 29, 0.55); +} + +.button-wide { + width: 100%; + min-height: 2.75rem; + margin-top: 1.25rem; +} + +.upload-progress { + margin-top: 1rem; +} + +.progress-row { + display: flex; + justify-content: space-between; + color: var(--muted-foreground); + font-size: 0.8rem; +} + +.progress { + height: 0.4rem; + margin-top: 0.55rem; + overflow: hidden; + border-radius: 999px; + background: var(--muted); +} + +.progress span { + display: block; + width: 100%; + height: 100%; + background: var(--primary); + transform-origin: left center; + transform: scaleX(0); + transition: transform 180ms ease; +} + +.upload-result { + border-color: rgba(244, 244, 245, 0.24); + background: rgba(244, 244, 245, 0.06); +} + +.result-title { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-weight: 650; +} + +.result-title svg { + color: var(--success); +} + +.result-actions { + display: flex; + gap: 0.5rem; +} + +.manage-link { + margin: 0.9rem 0 0; + color: var(--muted-foreground); + font-size: 0.78rem; + text-align: left; +} + +.manage-link a { + color: var(--foreground); + font-weight: 600; +} + +.result-list, +.download-list { + display: grid; + gap: 0.6rem; + margin-top: 1rem; +} + +.upload-queue { + margin-top: 1rem; +} + +.result-item, +.download-item { + min-width: 0; + display: flex; + align-items: center; + gap: 0.8rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--background); + padding: 0.75rem; +} + +.result-item > span, +.download-item > span { + min-width: 0; + max-width: 100%; + flex: 1; +} + +.result-item strong, +.download-item strong, +.result-item code, +.download-item code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-progress-side { + width: min(10rem, 32vw); + display: grid; + gap: 0.35rem; +} + +.file-progress-percent { + color: var(--muted-foreground); + font-size: 0.75rem; + text-align: right; +} + +.file-progress { + height: 0.35rem; + margin-top: 0; +} + +.result-item small, +.download-item small, +.result-item code, +.download-item code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 0.25rem; + color: var(--muted-foreground); + font-size: 0.78rem; +} diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css new file mode 100644 index 0000000..47f6854 --- /dev/null +++ b/backend/static/css/30-download.css @@ -0,0 +1,274 @@ +.download-view { + width: min(38rem, calc(100% - 2rem)); + min-height: calc(100vh - 7.25rem); + margin: 0 auto; + padding: 2.5rem 0; + display: grid; + place-items: center; +} + +.download-view-wide { + width: min(58rem, calc(100% - 2rem)); +} + +.download-card { + text-align: center; +} + +.file-emblem { + width: 4rem; + height: 4rem; + margin: 0 auto 1rem; + display: grid; + place-items: center; + border-radius: var(--radius); + background: var(--muted); + color: var(--muted-foreground); +} + +.file-emblem svg { + width: 1.75rem; + height: 1.75rem; +} + +.badge-row { + margin-top: 1rem; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.5rem; +} + + +.download-item { + color: var(--foreground); + text-align: left; + text-decoration: none; +} + +.view-toolbar { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; +} + +.button.is-active { + background: var(--primary); + color: var(--primary-foreground); +} + +.file-browser { + transition: opacity 160ms ease; +} + +.file-card { + position: relative; +} + +.thumb-link { + display: block; + overflow: hidden; + flex: 0 0 4.75rem; + width: 4.75rem; + aspect-ratio: 16 / 10; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--muted); +} + +.thumb-link img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.file-main { + min-width: 0; + max-width: 100%; + flex: 1; + color: var(--foreground); + text-decoration: none; +} + +.file-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.preview-action [hidden] { + display: none; +} + +.file-browser.is-thumbs { + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); +} + +.file-browser.is-thumbs .file-card { + display: grid; + min-width: 0; + align-content: start; + gap: 0.7rem; +} + +.file-browser.is-thumbs .file-main { + width: 100%; +} + +.file-browser.is-thumbs .thumb-link { + width: 100%; + flex-basis: auto; +} + +.file-browser.is-thumbs .button { + width: 100%; +} + +.file-browser.is-thumbs .file-actions { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.file-browser.images-only .file-card:not([data-kind="image"]) { + display: none; +} + +.context-menu { + position: fixed; + z-index: 30; + width: 10.75rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: color-mix(in srgb, var(--card) 96%, #000); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46); + padding: 0.4rem; +} + +.context-menu[hidden] { + display: none; +} + +.context-menu button { + width: 100%; + min-height: 2.05rem; + justify-content: flex-start; + border-radius: calc(var(--radius) - 0.25rem); + padding: 0.42rem 0.5rem; + color: var(--foreground); + font-size: 0.8rem; +} + +.context-menu button:hover, +.context-menu button:focus-visible, +.context-menu button.is-copied { + background: var(--accent); +} + +.context-menu-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.1rem 0.1rem 0.2rem 0.45rem; +} + +.context-menu-top small { + color: color-mix(in srgb, var(--muted-foreground) 74%, transparent); + font-size: 0.72rem; + font-weight: 600; +} + +.context-menu-icons { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.context-menu-icons button { + width: 1.9rem; + min-height: 1.9rem; + padding: 0; + justify-content: center; +} + +.context-menu hr { + height: 1px; + margin: 0.35rem 0.2rem; + border: 0; + background: var(--border); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.unlock-form { + margin: 1rem auto 0; + display: grid; + max-width: 22rem; + gap: 0.75rem; +} + +.manage-details { + display: grid; + gap: 0.5rem; + margin: 1rem 0 0; + text-align: left; +} + +.manage-details div { + display: flex; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid var(--border); + padding: 0.45rem 0; +} + +.manage-details dt, +.manage-details dd { + margin: 0; + min-width: 0; +} + +.manage-details dt { + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 600; +} + +.manage-details dd { + color: var(--foreground); + font-size: 0.84rem; + text-align: right; +} + +.preview-stage { + overflow: hidden; + margin-bottom: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--background); +} + +.preview-stage img, +.preview-stage video { + width: 100%; + max-height: 55vh; + display: block; + object-fit: contain; +} + +.preview-stage audio { + width: calc(100% - 2rem); + margin: 1rem; +} diff --git a/backend/static/css/40-docs.css b/backend/static/css/40-docs.css new file mode 100644 index 0000000..0e6dbf4 --- /dev/null +++ b/backend/static/css/40-docs.css @@ -0,0 +1,104 @@ +.admin-view { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 3rem; +} + +.docs-view { + width: min(72rem, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem 0 3rem; +} + +.docs-header { + max-width: 44rem; +} + +.docs-header p { + margin: 0.55rem 0 0; + color: var(--muted-foreground); + line-height: 1.55; +} + +.docs-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.docs-card { + box-shadow: none; +} + +.docs-card h2 { + margin: 0; + font-size: 1rem; +} + +.docs-card p { + margin: 0.65rem 0 0; + color: var(--muted-foreground); + font-size: 0.88rem; + line-height: 1.55; +} + +.docs-card-wide { + grid-column: 1 / -1; +} + +.endpoint-list, +.field-grid { + display: grid; + gap: 0.65rem; + margin: 1rem 0 0; +} + +.endpoint-list div, +.field-grid { + min-width: 0; +} + +.endpoint-list div { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 0.75rem; + align-items: baseline; +} + +.endpoint-list dt, +.endpoint-list dd { + margin: 0; + min-width: 0; +} + +.endpoint-list dt, +.field-grid span { + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 700; +} + +.endpoint-list dd code { + display: block; +} + +.docs-steps { + margin: 0.85rem 0 0; + padding-left: 1.1rem; + color: var(--muted-foreground); + font-size: 0.88rem; + line-height: 1.6; +} + +.docs-steps li + li { + margin-top: 0.35rem; +} + +.field-grid { + grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr); +} + +.field-grid p { + margin: 0; +} diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css new file mode 100644 index 0000000..48d90f4 --- /dev/null +++ b/backend/static/css/50-admin.css @@ -0,0 +1,174 @@ +.admin-header, +.table-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.kicker { + margin: 0 0 0.4rem; + color: var(--muted-foreground); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.8rem; + margin-top: 1.5rem; +} + +.metric-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(24, 24, 27, 0.78); + padding: 1rem; +} + +.metric-card span, +.table-header p { + display: block; + color: var(--muted-foreground); + font-size: 0.78rem; +} + +.metric-card strong { + display: block; + margin-top: 0.4rem; + color: var(--foreground); + font-size: 1.35rem; +} + +.admin-table-card { + margin-top: 1rem; +} + +.table-header h2 { + margin: 0; + font-size: 1.05rem; +} + +.table-header p { + margin: 0.3rem 0 0; +} + +.admin-table-wrap { + overflow-x: auto; + margin-top: 1rem; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.admin-table th, +.admin-table td { + border-bottom: 1px solid var(--border); + padding: 0.75rem; + text-align: left; + vertical-align: middle; +} + +.admin-table th { + color: var(--muted-foreground); + font-weight: 650; +} + +.table-actions { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 0.5rem; +} + +.table-actions form { + margin: 0; +} + + +/* Inline row edit (details/summary in table cells) */ +.row-edit { + margin-top: 0.35rem; +} + +.row-edit > summary { + display: inline-flex; + align-items: center; + color: var(--muted-foreground); + font-size: 0.72rem; + cursor: pointer; + list-style: none; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + opacity: 0.75; +} + +.row-edit > summary::-webkit-details-marker { display: none; } + +.row-edit[open] > summary { + opacity: 1; +} + +.row-edit-form { + display: flex; + gap: 0.4rem; + align-items: center; + margin-top: 0.4rem; +} + +.row-edit-form input, +.row-edit-form select { + width: auto; + flex: 1; + min-width: 8rem; + min-height: 1.9rem; + font-size: 0.8rem; + padding: 0.25rem 0.55rem; +} + +.storage-edit-form { + width: min(34rem, calc(100vw - 2rem)); + display: grid; + grid-template-columns: 1fr 1fr; + align-items: end; + gap: 0.6rem; + padding: 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + box-shadow: none; +} + +.storage-edit-form label { + display: grid; + gap: 0.25rem; +} + +.storage-edit-form label span { + color: var(--muted-foreground); + font-size: 0.72rem; +} + +.storage-edit-form textarea { + min-height: 5rem; + resize: vertical; +} + +.storage-edit-form .checkbox-field, +.storage-edit-form button { + align-self: center; +} + +@media (max-width: 720px) { + .storage-edit-form { + position: static; + grid-template-columns: 1fr; + width: 100%; + } +} diff --git a/backend/static/css/60-storage.css b/backend/static/css/60-storage.css new file mode 100644 index 0000000..f97d402 --- /dev/null +++ b/backend/static/css/60-storage.css @@ -0,0 +1,244 @@ +/* ── Storage card UI ─────────────────────────────────────────────────────── */ + +.storage-stack { + display: grid; + gap: 0.85rem; +} + +.storage-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 94%, transparent); + overflow: hidden; +} + +.storage-card.is-local { + border-left: 3px solid rgba(125, 211, 252, 0.45); +} + +.storage-card.is-editing { + border-color: rgba(125, 211, 252, 0.35); + box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12); +} + +.storage-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + flex-wrap: wrap; +} + +.storage-card-identity { + display: flex; + align-items: center; + gap: 0.85rem; + min-width: 0; +} + +.storage-card-icon { + display: grid; + place-items: center; + flex-shrink: 0; + width: 2.4rem; + height: 2.4rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--muted); + color: var(--muted-foreground); +} + +.storage-card-icon svg { + width: 1.2rem; + height: 1.2rem; +} + +.storage-card-name { + display: block; + font-size: 0.95rem; + font-weight: 650; + color: var(--foreground); +} + +.storage-card-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.3rem; +} + +.storage-card-usage { + color: var(--muted-foreground); + font-size: 0.78rem; +} + +.storage-card-actions { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; +} + +/* View-mode summary */ +.storage-card-summary { + display: flex; + flex-wrap: wrap; + gap: 0 1.75rem; + padding: 0.65rem 1.1rem 0.9rem; + border-top: 1px solid var(--border); +} + +.storage-detail { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 8rem; +} + +.storage-detail > span:first-child, +.storage-detail > code:first-child { + color: var(--muted-foreground); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.storage-detail > span:last-child, +.storage-detail > code:last-child { + font-size: 0.82rem; + color: var(--foreground); + word-break: break-all; +} + +.storage-detail-test > span:last-child { + font-size: 0.8rem; +} + +.storage-detail-test.is-ok > span:last-child { color: #86efac; } +.storage-detail-test.is-err > span:last-child { color: #fca5a5; } + +/* Edit-mode body */ +.storage-card:not(.is-editing) .storage-card-body { display: none; } +.storage-card.is-editing .storage-card-summary { display: none; } +.storage-card.is-editing .storage-edit-trigger { display: none; } + +.storage-card-body { + border-top: 1px solid var(--border); + padding: 1rem 1.1rem; +} + +.storage-card-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + align-items: end; +} + +.storage-card-fields label { + display: grid; + gap: 0.28rem; + color: var(--muted-foreground); + font-size: 0.8rem; +} + +.storage-card-fields label span { + font-size: 0.72rem; + color: var(--muted-foreground); +} + +.storage-card-fields textarea { + min-height: 5rem; + resize: vertical; +} + +.storage-card-fields .checkbox-field { + align-self: center; +} + +.storage-card-edit-bar { + grid-column: 1 / -1; + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; + padding-top: 0.65rem; + border-top: 1px solid var(--border); +} + +@media (max-width: 640px) { + .storage-card-fields { + grid-template-columns: 1fr; + } +} + +/* Add storage section */ +.storage-add-section { + display: grid; + gap: 0.75rem; +} + +.storage-add-controls { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.storage-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); + gap: 0.6rem; +} + +.storage-type-option { + display: grid; + grid-template-rows: auto auto auto; + gap: 0.3rem; + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + color: var(--foreground); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 120ms ease, background 120ms ease; +} + +.storage-type-option:hover { + border-color: rgba(125, 211, 252, 0.35); + background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3)); +} + +.storage-type-option svg { + width: 1.5rem; + height: 1.5rem; + color: var(--muted-foreground); + margin-bottom: 0.2rem; +} + +.storage-type-option strong { + font-size: 0.88rem; + font-weight: 650; +} + +.storage-type-option span { + font-size: 0.78rem; + color: var(--muted-foreground); + line-height: 1.4; +} + +.storage-new-card { + border: 1px dashed rgba(125, 211, 252, 0.4); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15)); +} + +.storage-new-card .storage-card-header { + border-bottom: 1px solid var(--border); +} + +.storage-new-card .storage-card-body { + border-top: none; +} diff --git a/backend/static/css/70-tokens.css b/backend/static/css/70-tokens.css new file mode 100644 index 0000000..6d4661a --- /dev/null +++ b/backend/static/css/70-tokens.css @@ -0,0 +1,59 @@ +/* ── Access tokens ───────────────────────────────────────────────────────── */ + +.token-create-form { + display: flex; + align-items: end; + gap: 0.65rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.token-create-form label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; + flex: 1; + min-width: 14rem; +} + +.token-reveal { + margin-bottom: 1rem; + padding: 0.9rem 1rem; + border: 1px solid rgba(134, 239, 172, 0.3); + border-radius: var(--radius); + background: rgba(134, 239, 172, 0.08); +} + +.token-reveal-title { + margin: 0 0 0.6rem; + font-size: 0.85rem; + font-weight: 650; + color: #86efac; +} + +.token-reveal-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.token-reveal-value { + flex: 1; + min-width: 0; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: var(--background); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.82rem; + word-break: break-all; +} + +.token-reveal .muted-copy { + margin: 0.6rem 0 0; +} + +.token-reveal .muted-copy code { + word-break: break-all; +} diff --git a/backend/static/css/90-responsive.css b/backend/static/css/90-responsive.css new file mode 100644 index 0000000..acad13f --- /dev/null +++ b/backend/static/css/90-responsive.css @@ -0,0 +1,94 @@ +@media (max-width: 720px) { + .nav-links { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + } + + .upload-view, + .download-view { + min-height: auto; + padding: 2rem 0; + } + + .option-grid, + .form-footer, + .result-header, + .site-footer { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .option-grid { + grid-template-columns: 1fr; + } + + .docs-grid, + .field-grid, + .app-shell, + .settings-form { + grid-template-columns: 1fr; + } + + .app-sidebar { + position: static; + } + + .endpoint-list div { + grid-template-columns: 1fr; + gap: 0.25rem; + } + + .result-actions { + width: 100%; + } + + .file-progress-side { + width: 100%; + } + + .result-actions .button { + flex: 1; + } + + h1 { + font-size: 1.65rem; + } + + .drop-zone { + min-height: 15rem; + } + + .admin-header, + .table-header { + flex-direction: column; + align-items: stretch; + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .tabs-bar { + flex-direction: column; + align-items: stretch; + } + + .settings-section { + grid-template-columns: 1fr; + } + + .new-collection-body { + position: static; + width: 100%; + margin-top: 0.5rem; + box-shadow: none; + } +} + +@media (max-width: 640px) { + .storage-card-fields { + grid-template-columns: 1fr; + } +} diff --git a/backend/static/css/app.css b/backend/static/css/app.css deleted file mode 100644 index 2fa950d..0000000 --- a/backend/static/css/app.css +++ /dev/null @@ -1,1880 +0,0 @@ -:root { - color-scheme: dark; - --background: #09090b; - --foreground: #fafafa; - --card: #18181b; - --card-foreground: #fafafa; - --muted: #27272a; - --muted-foreground: #a1a1aa; - --accent: #27272a; - --accent-foreground: #fafafa; - --border: rgba(255, 255, 255, 0.1); - --input: rgba(255, 255, 255, 0.15); - --primary: #f4f4f5; - --primary-foreground: #18181b; - --ring: #71717a; - --success: #86efac; - --radius: 0.625rem; - --shadow: 0 24px 70px rgba(0, 0, 0, 0.45); -} - -* { - box-sizing: border-box; -} - -html { - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: var(--background); - color: var(--foreground); -} - -body { - min-height: 100vh; - margin: 0; - display: flex; - flex-direction: column; - background: - radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem), - linear-gradient(180deg, #09090b 0%, #0f0f12 100%); -} - -a { - color: inherit; -} - -svg { - width: 1rem; - height: 1rem; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; -} - -:focus-visible { - outline: 2px solid var(--ring); - outline-offset: 2px; -} - -.skip-link { - position: absolute; - left: 1rem; - top: -4rem; - z-index: 10; - padding: 0.75rem 1rem; - border-radius: var(--radius); - background: var(--primary); - color: var(--primary-foreground); -} - -.skip-link:focus { - top: 1rem; -} - -.site-header { - position: sticky; - top: 0; - z-index: 20; - border-bottom: 1px solid var(--border); - background: rgba(9, 9, 11, 0.84); - backdrop-filter: blur(14px); -} - -.nav { - width: min(72rem, calc(100% - 2rem)); - min-height: 3.5rem; - margin: 0 auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.brand, -.nav-links, -.footer-links, -.inline-form { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.inline-form { - margin: 0; -} - -.brand { - font-weight: 650; - text-decoration: none; -} - -.brand-mark { - width: 1.75rem; - height: 1.75rem; - display: grid; - place-items: center; - border-radius: calc(var(--radius) - 0.125rem); - background: var(--primary); - color: var(--primary-foreground); - font-size: 0.85rem; - font-weight: 800; -} - -main { - flex: 1; -} - -.upload-view { - width: min(48rem, calc(100% - 2rem)); - min-height: calc(100vh - 7.25rem); - margin: 0 auto; - padding: 2.5rem 0; - display: flex; - flex-direction: column; - justify-content: center; - gap: 1.5rem; -} - -.hero-copy { - text-align: center; -} - -h1 { - margin: 0; - color: var(--foreground); - font-size: 2rem; - line-height: 1.12; - font-weight: 650; - letter-spacing: 0; -} - -.file-name { - display: block; - max-width: 100%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.hero-copy p, -.download-subtitle, -.panel-header p { - margin: 0.55rem 0 0; - color: var(--muted-foreground); - font-size: 0.95rem; - line-height: 1.5; -} - -.card { - width: 100%; - border: 1px solid var(--border); - border-radius: var(--radius); - background: color-mix(in srgb, var(--card) 94%, transparent); - box-shadow: var(--shadow); -} - -.card-content { - 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 { - display: flex; - align-items: center; - gap: 0.55rem; - 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; - gap: 1.5rem; -} - -.settings-form-narrow { - grid-template-columns: minmax(0, 1fr); - gap: 0.9rem; -} - -.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 { - min-height: 19rem; - display: grid; - place-items: center; - align-content: center; - gap: 0.65rem; - padding: 2rem; - border: 2px dashed var(--border); - border-radius: var(--radius); - background: rgba(39, 39, 42, 0.42); - text-align: center; - cursor: pointer; - transition: border-color 160ms ease, background 160ms ease; -} - -.drop-zone:hover, -.drop-zone.is-dragging { - border-color: var(--primary); - background: rgba(39, 39, 42, 0.68); -} - -.drop-zone input { - position: absolute; - inline-size: 1px; - block-size: 1px; - opacity: 0; - pointer-events: none; -} - -.drop-icon { - width: 2.75rem; - height: 2.75rem; - display: grid; - place-items: center; - color: var(--muted-foreground); -} - -.drop-icon svg { - width: 2.5rem; - height: 2.5rem; -} - -.drop-title { - font-size: 1rem; - font-weight: 650; -} - -.drop-copy, -.drop-meta { - color: var(--muted-foreground); - font-size: 0.9rem; -} - -.drop-meta { - margin-top: 0.75rem; - font-size: 0.78rem; -} - -.advanced-options { - margin-top: 1rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: rgba(39, 39, 42, 0.28); - padding: 0.75rem 0.9rem; -} - -.advanced-options summary { - display: inline-flex; - align-items: center; - gap: 0.35rem; - color: var(--foreground); - cursor: pointer; - font-size: 0.875rem; - font-weight: 600; -} - -.option-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0.9rem; - margin-top: 1rem; -} - -.checkbox-field { - display: flex; - align-items: center; - gap: 0.55rem; -} - -.checkbox-field input { - width: 1rem; - min-height: 1rem; -} - -.checkbox-field span { - margin: 0; - color: var(--muted-foreground); -} - -label span { - display: block; - margin-bottom: 0.4rem; - color: var(--foreground); - font-size: 0.8rem; - font-weight: 600; -} - -input, -select, -button { - font: inherit; -} - -input, -select { - width: 100%; - min-height: 2.25rem; - border: 1px solid var(--input); - border-radius: calc(var(--radius) - 0.125rem); - padding: 0.45rem 0.7rem; - background: var(--background); - color: var(--foreground); -} - -input::placeholder { - color: var(--muted-foreground); -} - -input:disabled { - opacity: 0.55; - cursor: not-allowed; -} - -.form-footer, -.result-header { - margin-top: 1rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.form-footer p, -#result-meta { - margin: 0; - color: var(--muted-foreground); - font-size: 0.82rem; -} - -.button, -button { - min-height: 2.25rem; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.45rem; - border: 1px solid transparent; - border-radius: calc(var(--radius) - 0.125rem); - padding: 0.45rem 0.85rem; - color: var(--foreground); - background: transparent; - font: inherit; - font-size: 0.875rem; - font-weight: 600; - line-height: 1; - text-decoration: none; - cursor: pointer; -} - -.button-primary { - background: var(--primary); - color: var(--primary-foreground); -} - -.button-primary:hover { - background: #e4e4e7; -} - -.button-outline { - border-color: var(--border); - background: var(--background); -} - -.button-outline:hover, -.button-ghost:hover { - background: var(--accent); -} - -.button-danger { - border-color: rgba(248, 113, 113, 0.28); - background: rgba(127, 29, 29, 0.3); - color: #fecaca; -} - -.button-danger:hover { - background: rgba(127, 29, 29, 0.55); -} - -.button-wide { - width: 100%; - min-height: 2.75rem; - margin-top: 1.25rem; -} - -.upload-progress { - margin-top: 1rem; -} - -.progress-row { - display: flex; - justify-content: space-between; - color: var(--muted-foreground); - font-size: 0.8rem; -} - -.progress { - height: 0.4rem; - margin-top: 0.55rem; - overflow: hidden; - border-radius: 999px; - background: var(--muted); -} - -.progress span { - display: block; - width: 100%; - height: 100%; - background: var(--primary); - transform-origin: left center; - transform: scaleX(0); - transition: transform 180ms ease; -} - -.upload-result { - border-color: rgba(244, 244, 245, 0.24); - background: rgba(244, 244, 245, 0.06); -} - -.result-title { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-weight: 650; -} - -.result-title svg { - color: var(--success); -} - -.result-actions { - display: flex; - gap: 0.5rem; -} - -.manage-link { - margin: 0.9rem 0 0; - color: var(--muted-foreground); - font-size: 0.78rem; - text-align: left; -} - -.manage-link a { - color: var(--foreground); - font-weight: 600; -} - -.result-list, -.download-list { - display: grid; - gap: 0.6rem; - margin-top: 1rem; -} - -.upload-queue { - margin-top: 1rem; -} - -.result-item, -.download-item { - min-width: 0; - display: flex; - align-items: center; - gap: 0.8rem; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 0.125rem); - background: var(--background); - padding: 0.75rem; -} - -.result-item > span, -.download-item > span { - min-width: 0; - max-width: 100%; - flex: 1; -} - -.result-item strong, -.download-item strong, -.result-item code, -.download-item code { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-progress-side { - width: min(10rem, 32vw); - display: grid; - gap: 0.35rem; -} - -.file-progress-percent { - color: var(--muted-foreground); - font-size: 0.75rem; - text-align: right; -} - -.file-progress { - height: 0.35rem; - margin-top: 0; -} - -.result-item small, -.download-item small, -.result-item code, -.download-item code { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-top: 0.25rem; - color: var(--muted-foreground); - font-size: 0.78rem; -} - -code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - color: var(--muted-foreground); -} - -pre { - overflow-x: auto; - margin: 0.8rem 0 0; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 0.125rem); - background: var(--background); - padding: 0.9rem; - text-align: left; -} - -pre code { - display: block; - margin: 0; - overflow: visible; - white-space: pre; -} - -.download-view { - width: min(38rem, calc(100% - 2rem)); - min-height: calc(100vh - 7.25rem); - margin: 0 auto; - padding: 2.5rem 0; - display: grid; - place-items: center; -} - -.download-view-wide { - width: min(58rem, calc(100% - 2rem)); -} - -.download-card { - text-align: center; -} - -.file-emblem { - width: 4rem; - height: 4rem; - margin: 0 auto 1rem; - display: grid; - place-items: center; - border-radius: var(--radius); - background: var(--muted); - color: var(--muted-foreground); -} - -.file-emblem svg { - width: 1.75rem; - height: 1.75rem; -} - -.badge-row { - margin-top: 1rem; - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 0.5rem; -} - -.badge { - display: inline-flex; - align-items: center; - min-height: 1.5rem; - border-radius: 999px; - background: var(--muted); - color: var(--muted-foreground); - padding: 0.2rem 0.6rem; - font-size: 0.75rem; - font-weight: 600; -} - -.download-item { - color: var(--foreground); - text-align: left; - text-decoration: none; -} - -.view-toolbar { - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; -} - -.button.is-active { - background: var(--primary); - color: var(--primary-foreground); -} - -.file-browser { - transition: opacity 160ms ease; -} - -.file-card { - position: relative; -} - -.thumb-link { - display: block; - overflow: hidden; - flex: 0 0 4.75rem; - width: 4.75rem; - aspect-ratio: 16 / 10; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 0.125rem); - background: var(--muted); -} - -.thumb-link img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; -} - -.file-main { - min-width: 0; - max-width: 100%; - flex: 1; - color: var(--foreground); - text-decoration: none; -} - -.file-actions { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.preview-action [hidden] { - display: none; -} - -.file-browser.is-thumbs { - grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); -} - -.file-browser.is-thumbs .file-card { - display: grid; - min-width: 0; - align-content: start; - gap: 0.7rem; -} - -.file-browser.is-thumbs .file-main { - width: 100%; -} - -.file-browser.is-thumbs .thumb-link { - width: 100%; - flex-basis: auto; -} - -.file-browser.is-thumbs .button { - width: 100%; -} - -.file-browser.is-thumbs .file-actions { - width: 100%; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.file-browser.images-only .file-card:not([data-kind="image"]) { - display: none; -} - -.context-menu { - position: fixed; - z-index: 30; - width: 10.75rem; - overflow: hidden; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 0.125rem); - background: color-mix(in srgb, var(--card) 96%, #000); - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46); - padding: 0.4rem; -} - -.context-menu[hidden] { - display: none; -} - -.context-menu button { - width: 100%; - min-height: 2.05rem; - justify-content: flex-start; - border-radius: calc(var(--radius) - 0.25rem); - padding: 0.42rem 0.5rem; - color: var(--foreground); - font-size: 0.8rem; -} - -.context-menu button:hover, -.context-menu button:focus-visible, -.context-menu button.is-copied { - background: var(--accent); -} - -.context-menu-top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.1rem 0.1rem 0.2rem 0.45rem; -} - -.context-menu-top small { - color: color-mix(in srgb, var(--muted-foreground) 74%, transparent); - font-size: 0.72rem; - font-weight: 600; -} - -.context-menu-icons { - display: inline-flex; - align-items: center; - gap: 0.2rem; -} - -.context-menu-icons button { - width: 1.9rem; - min-height: 1.9rem; - padding: 0; - justify-content: center; -} - -.context-menu hr { - height: 1px; - margin: 0.35rem 0.2rem; - border: 0; - background: var(--border); -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; -} - -.unlock-form { - margin: 1rem auto 0; - display: grid; - max-width: 22rem; - gap: 0.75rem; -} - -.manage-details { - display: grid; - gap: 0.5rem; - margin: 1rem 0 0; - text-align: left; -} - -.manage-details div { - display: flex; - justify-content: space-between; - gap: 1rem; - border-bottom: 1px solid var(--border); - padding: 0.45rem 0; -} - -.manage-details dt, -.manage-details dd { - margin: 0; - min-width: 0; -} - -.manage-details dt { - color: var(--muted-foreground); - font-size: 0.78rem; - font-weight: 600; -} - -.manage-details dd { - color: var(--foreground); - font-size: 0.84rem; - text-align: right; -} - -.preview-stage { - overflow: hidden; - margin-bottom: 1rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--background); -} - -.preview-stage img, -.preview-stage video { - width: 100%; - max-height: 55vh; - display: block; - object-fit: contain; -} - -.preview-stage audio { - width: calc(100% - 2rem); - margin: 1rem; -} - -.site-footer { - width: min(72rem, calc(100% - 2rem)); - margin: 0 auto; - padding: 1rem 0; - display: flex; - justify-content: space-between; - gap: 1rem; - color: var(--muted-foreground); - font-size: 0.78rem; -} - -.footer-links a { - text-decoration: none; -} - -.form-error { - margin: 1rem 0 0; - color: #fecaca; - font-size: 0.9rem; -} - -.admin-view { - width: min(72rem, calc(100% - 2rem)); - margin: 0 auto; - padding: 2rem 0 3rem; -} - -.docs-view { - width: min(72rem, calc(100% - 2rem)); - margin: 0 auto; - padding: 2rem 0 3rem; -} - -.docs-header { - max-width: 44rem; -} - -.docs-header p { - margin: 0.55rem 0 0; - color: var(--muted-foreground); - line-height: 1.55; -} - -.docs-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; - margin-top: 1.5rem; -} - -.docs-card { - box-shadow: none; -} - -.docs-card h2 { - margin: 0; - font-size: 1rem; -} - -.docs-card p { - margin: 0.65rem 0 0; - color: var(--muted-foreground); - font-size: 0.88rem; - line-height: 1.55; -} - -.docs-card-wide { - grid-column: 1 / -1; -} - -.endpoint-list, -.field-grid { - display: grid; - gap: 0.65rem; - margin: 1rem 0 0; -} - -.endpoint-list div, -.field-grid { - min-width: 0; -} - -.endpoint-list div { - display: grid; - grid-template-columns: 7rem minmax(0, 1fr); - gap: 0.75rem; - align-items: baseline; -} - -.endpoint-list dt, -.endpoint-list dd { - margin: 0; - min-width: 0; -} - -.endpoint-list dt, -.field-grid span { - color: var(--muted-foreground); - font-size: 0.78rem; - font-weight: 700; -} - -.endpoint-list dd code { - display: block; -} - -.docs-steps { - margin: 0.85rem 0 0; - padding-left: 1.1rem; - color: var(--muted-foreground); - font-size: 0.88rem; - line-height: 1.6; -} - -.docs-steps li + li { - margin-top: 0.35rem; -} - -.field-grid { - grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr); -} - -.field-grid p { - margin: 0; -} - -.admin-header, -.table-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.kicker { - margin: 0 0 0.4rem; - color: var(--muted-foreground); - font-size: 0.78rem; - font-weight: 700; - text-transform: uppercase; -} - -.metric-grid { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 0.8rem; - margin-top: 1.5rem; -} - -.metric-card { - border: 1px solid var(--border); - border-radius: var(--radius); - background: rgba(24, 24, 27, 0.78); - padding: 1rem; -} - -.metric-card span, -.table-header p { - display: block; - color: var(--muted-foreground); - font-size: 0.78rem; -} - -.metric-card strong { - display: block; - margin-top: 0.4rem; - color: var(--foreground); - font-size: 1.35rem; -} - -.admin-table-card { - margin-top: 1rem; -} - -.table-header h2 { - margin: 0; - font-size: 1.05rem; -} - -.table-header p { - margin: 0.3rem 0 0; -} - -.admin-table-wrap { - overflow-x: auto; - margin-top: 1rem; -} - -.admin-table { - width: 100%; - border-collapse: collapse; - font-size: 0.85rem; -} - -.admin-table th, -.admin-table td { - border-bottom: 1px solid var(--border); - padding: 0.75rem; - text-align: left; - vertical-align: middle; -} - -.admin-table th { - color: var(--muted-foreground); - font-weight: 650; -} - -.table-actions { - display: flex; - align-items: flex-start; - flex-wrap: wrap; - gap: 0.5rem; -} - -.table-actions form { - margin: 0; -} - -@media (max-width: 720px) { - .nav-links { - display: inline-flex; - flex-wrap: wrap; - justify-content: flex-end; - } - - .upload-view, - .download-view { - min-height: auto; - padding: 2rem 0; - } - - .option-grid, - .form-footer, - .result-header, - .site-footer { - grid-template-columns: 1fr; - flex-direction: column; - align-items: stretch; - } - - .option-grid { - grid-template-columns: 1fr; - } - - .docs-grid, - .field-grid, - .app-shell, - .settings-form { - grid-template-columns: 1fr; - } - - .app-sidebar { - position: static; - } - - .endpoint-list div { - grid-template-columns: 1fr; - gap: 0.25rem; - } - - .result-actions { - width: 100%; - } - - .file-progress-side { - width: 100%; - } - - .result-actions .button { - flex: 1; - } - - h1 { - font-size: 1.65rem; - } - - .drop-zone { - min-height: 15rem; - } - - .admin-header, - .table-header { - flex-direction: column; - align-items: stretch; - } - - .metric-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .tabs-bar { - flex-direction: column; - align-items: stretch; - } - - .settings-section { - grid-template-columns: 1fr; - } - - .new-collection-body { - position: static; - width: 100%; - margin-top: 0.5rem; - box-shadow: none; - } -} - -/* ── UX remaster ───────────────────────────────────────────── */ - -.button-sm { - min-height: 1.85rem; - padding: 0.3rem 0.65rem; - font-size: 0.8rem; -} - -/* Tab navigation */ -.tabs-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - flex-wrap: wrap; -} - -.tab-list { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.3rem; -} - -.tab { - display: inline-flex; - align-items: center; - height: 2rem; - padding: 0 0.75rem; - border-radius: 999px; - border: 1px solid transparent; - color: var(--muted-foreground); - font-size: 0.84rem; - font-weight: 500; - text-decoration: none; - transition: background 120ms, color 120ms, border-color 120ms; -} - -.tab:hover { - background: var(--muted); - color: var(--foreground); -} - -.tab.is-active { - border-color: var(--border); - background: var(--muted); - color: var(--foreground); - font-weight: 650; -} - -/* Sidebar structure */ -.sidebar-sep { - height: 1px; - border: 0; - background: var(--border); - margin: 0.5rem 0; -} - -.sidebar-nav { - display: grid; - gap: 0.25rem; -} - -/* Inline row edit (details/summary in table cells) */ -.row-edit { - margin-top: 0.35rem; -} - -.row-edit > summary { - display: inline-flex; - align-items: center; - color: var(--muted-foreground); - font-size: 0.72rem; - cursor: pointer; - list-style: none; - text-decoration: underline; - text-decoration-style: dotted; - text-underline-offset: 2px; - opacity: 0.75; -} - -.row-edit > summary::-webkit-details-marker { display: none; } - -.row-edit[open] > summary { - opacity: 1; -} - -.row-edit-form { - display: flex; - gap: 0.4rem; - align-items: center; - margin-top: 0.4rem; -} - -.row-edit-form input, -.row-edit-form select { - width: auto; - flex: 1; - min-width: 8rem; - min-height: 1.9rem; - font-size: 0.8rem; - padding: 0.25rem 0.55rem; -} - -.storage-edit-form { - width: min(34rem, calc(100vw - 2rem)); - display: grid; - grid-template-columns: 1fr 1fr; - align-items: end; - gap: 0.6rem; - padding: 0.85rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--card); - box-shadow: none; -} - -.storage-edit-form label { - display: grid; - gap: 0.25rem; -} - -.storage-edit-form label span { - color: var(--muted-foreground); - font-size: 0.72rem; -} - -.storage-edit-form textarea { - min-height: 5rem; - resize: vertical; -} - -.storage-edit-form .checkbox-field, -.storage-edit-form button { - align-self: center; -} - -@media (max-width: 720px) { - .storage-edit-form { - position: static; - grid-template-columns: 1fr; - width: 100%; - } -} - -/* Badge variants */ -.badge-active { - background: rgba(134, 239, 172, 0.12); - color: #86efac; -} - -.badge-disabled { - background: rgba(252, 165, 165, 0.1); - color: #fca5a5; -} - -.badge-expired { - opacity: 0.55; -} - -/* Collection create dropdown */ -.new-collection-drop { - position: relative; - flex-shrink: 0; -} - -.new-collection-drop > summary { - list-style: none; - cursor: pointer; -} - -.new-collection-drop > summary::-webkit-details-marker { display: none; } - -.new-collection-body { - position: absolute; - right: 0; - top: calc(100% + 0.5rem); - z-index: 10; - width: 15rem; - padding: 1rem; - background: color-mix(in srgb, var(--card) 97%, #000); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); - display: grid; - gap: 0.65rem; -} - -.new-collection-body label { - display: grid; - gap: 0.35rem; - color: var(--muted-foreground); - font-size: 0.82rem; -} - -/* Copyable URL field */ -.copy-field { - display: flex; - gap: 0.5rem; - align-items: center; - margin-top: 0.75rem; -} - -.copy-field input { - flex: 1; - min-width: 0; - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - font-size: 0.8rem; - color: var(--muted-foreground); -} - -/* Settings sections */ -.settings-section { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.9rem; -} - -.settings-section-title { - grid-column: 1 / -1; - margin: 0; - padding-bottom: 0.6rem; - border-bottom: 1px solid var(--border); - font-size: 0.875rem; - font-weight: 650; - color: var(--foreground); -} - -.settings-section .checkbox-field { - grid-column: 1 / -1; -} - -.settings-section label { - display: grid; - gap: 0.35rem; - color: var(--muted-foreground); - font-size: 0.82rem; -} - -/* Quota form in admin users table */ -.quota-form { - display: flex; - gap: 0.4rem; - align-items: center; - margin: 0; -} - -.quota-form input { - width: 6.5rem; - min-width: 0; -} - -/* Nav username indicator in header */ -.nav-username { - max-width: 8rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* ── Storage card UI ─────────────────────────────────────────────────────── */ - -.storage-stack { - display: grid; - gap: 0.85rem; -} - -.storage-card { - border: 1px solid var(--border); - border-radius: var(--radius); - background: color-mix(in srgb, var(--card) 94%, transparent); - overflow: hidden; -} - -.storage-card.is-local { - border-left: 3px solid rgba(125, 211, 252, 0.45); -} - -.storage-card.is-editing { - border-color: rgba(125, 211, 252, 0.35); - box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12); -} - -.storage-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 1rem 1.1rem; - flex-wrap: wrap; -} - -.storage-card-identity { - display: flex; - align-items: center; - gap: 0.85rem; - min-width: 0; -} - -.storage-card-icon { - display: grid; - place-items: center; - flex-shrink: 0; - width: 2.4rem; - height: 2.4rem; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 0.125rem); - background: var(--muted); - color: var(--muted-foreground); -} - -.storage-card-icon svg { - width: 1.2rem; - height: 1.2rem; -} - -.storage-card-name { - display: block; - font-size: 0.95rem; - font-weight: 650; - color: var(--foreground); -} - -.storage-card-meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.4rem; - margin-top: 0.3rem; -} - -.storage-card-usage { - color: var(--muted-foreground); - font-size: 0.78rem; -} - -.storage-card-actions { - display: flex; - align-items: center; - gap: 0.4rem; - flex-shrink: 0; -} - -/* View-mode summary */ -.storage-card-summary { - display: flex; - flex-wrap: wrap; - gap: 0 1.75rem; - padding: 0.65rem 1.1rem 0.9rem; - border-top: 1px solid var(--border); -} - -.storage-detail { - display: flex; - flex-direction: column; - gap: 0.15rem; - min-width: 8rem; -} - -.storage-detail > span:first-child, -.storage-detail > code:first-child { - color: var(--muted-foreground); - font-size: 0.72rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.storage-detail > span:last-child, -.storage-detail > code:last-child { - font-size: 0.82rem; - color: var(--foreground); - word-break: break-all; -} - -.storage-detail-test > span:last-child { - font-size: 0.8rem; -} - -.storage-detail-test.is-ok > span:last-child { color: #86efac; } -.storage-detail-test.is-err > span:last-child { color: #fca5a5; } - -/* Edit-mode body */ -.storage-card:not(.is-editing) .storage-card-body { display: none; } -.storage-card.is-editing .storage-card-summary { display: none; } -.storage-card.is-editing .storage-edit-trigger { display: none; } - -.storage-card-body { - border-top: 1px solid var(--border); - padding: 1rem 1.1rem; -} - -.storage-card-fields { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.75rem; - align-items: end; -} - -.storage-card-fields label { - display: grid; - gap: 0.28rem; - color: var(--muted-foreground); - font-size: 0.8rem; -} - -.storage-card-fields label span { - font-size: 0.72rem; - color: var(--muted-foreground); -} - -.storage-card-fields textarea { - min-height: 5rem; - resize: vertical; -} - -.storage-card-fields .checkbox-field { - align-self: center; -} - -.storage-card-edit-bar { - grid-column: 1 / -1; - display: flex; - gap: 0.5rem; - margin-top: 0.25rem; - padding-top: 0.65rem; - border-top: 1px solid var(--border); -} - -@media (max-width: 640px) { - .storage-card-fields { - grid-template-columns: 1fr; - } -} - -/* Add storage section */ -.storage-add-section { - display: grid; - gap: 0.75rem; -} - -.storage-add-controls { - display: flex; - align-items: center; - gap: 0.65rem; -} - -.storage-type-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); - gap: 0.6rem; -} - -.storage-type-option { - display: grid; - grid-template-rows: auto auto auto; - gap: 0.3rem; - padding: 0.9rem 1rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--card); - color: var(--foreground); - font: inherit; - text-align: left; - cursor: pointer; - transition: border-color 120ms ease, background 120ms ease; -} - -.storage-type-option:hover { - border-color: rgba(125, 211, 252, 0.35); - background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3)); -} - -.storage-type-option svg { - width: 1.5rem; - height: 1.5rem; - color: var(--muted-foreground); - margin-bottom: 0.2rem; -} - -.storage-type-option strong { - font-size: 0.88rem; - font-weight: 650; -} - -.storage-type-option span { - font-size: 0.78rem; - color: var(--muted-foreground); - line-height: 1.4; -} - -.storage-new-card { - border: 1px dashed rgba(125, 211, 252, 0.4); - border-radius: var(--radius); - background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15)); -} - -.storage-new-card .storage-card-header { - border-bottom: 1px solid var(--border); -} - -.storage-new-card .storage-card-body { - border-top: none; -} - -/* ── Access tokens ───────────────────────────────────────────────────────── */ - -.token-create-form { - display: flex; - align-items: end; - gap: 0.65rem; - flex-wrap: wrap; - margin-bottom: 1rem; -} - -.token-create-form label { - display: grid; - gap: 0.35rem; - color: var(--muted-foreground); - font-size: 0.82rem; - flex: 1; - min-width: 14rem; -} - -.token-reveal { - margin-bottom: 1rem; - padding: 0.9rem 1rem; - border: 1px solid rgba(134, 239, 172, 0.3); - border-radius: var(--radius); - background: rgba(134, 239, 172, 0.08); -} - -.token-reveal-title { - margin: 0 0 0.6rem; - font-size: 0.85rem; - font-weight: 650; - color: #86efac; -} - -.token-reveal-row { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.token-reveal-value { - flex: 1; - min-width: 0; - padding: 0.5rem 0.65rem; - border: 1px solid var(--border); - border-radius: calc(var(--radius) - 0.125rem); - background: var(--background); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 0.82rem; - word-break: break-all; -} - -.token-reveal .muted-copy { - margin: 0.6rem 0 0; -} - -.token-reveal .muted-copy code { - word-break: break-all; -} diff --git a/backend/static/js/00-utils.js b/backend/static/js/00-utils.js new file mode 100644 index 0000000..03ae129 --- /dev/null +++ b/backend/static/js/00-utils.js @@ -0,0 +1,62 @@ +(function () { + window.Warpbox = window.Warpbox || {}; + + window.Warpbox.openInNewTab = function openInNewTab(url) { + window.open(url, "_blank", "noopener,noreferrer"); + }; + + window.Warpbox.writeClipboard = async function writeClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + }; + + window.Warpbox.copyText = async function copyText(text, button, copiedLabel) { + if (!text || !button) { + return; + } + await window.Warpbox.writeClipboard(text); + const previous = button.textContent; + button.textContent = copiedLabel; + setTimeout(() => { + button.textContent = previous; + }, 1400); + }; + + window.Warpbox.formatDate = function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + window.Warpbox.formatBytes = function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ["KiB", "MiB", "GiB", "TiB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(1)} ${units[unit]}`; + }; +})(); diff --git a/backend/static/js/10-file-browser.js b/backend/static/js/10-file-browser.js new file mode 100644 index 0000000..704fba5 --- /dev/null +++ b/backend/static/js/10-file-browser.js @@ -0,0 +1,191 @@ +(function () { + const fileBrowser = document.querySelector("[data-file-browser]"); + const viewButtons = document.querySelectorAll("[data-view-button]"); + const previewImages = document.querySelector("[data-preview-images]"); + const previewActions = document.querySelectorAll("[data-preview-action]"); + const fileContextMenu = document.querySelector("[data-file-context-menu]"); + + let ctrlCopyMode = false; + let contextFile = null; + const contextMenuCloseDistance = 80; + + if (fileBrowser) { + viewButtons.forEach((button) => { + button.addEventListener("click", () => { + const view = button.getAttribute("data-view-button"); + fileBrowser.classList.toggle("is-list", view === "list"); + fileBrowser.classList.toggle("is-thumbs", view === "thumbs"); + viewButtons.forEach((item) => item.classList.toggle("is-active", item === button)); + }); + }); + + if (previewImages) { + previewImages.addEventListener("click", () => { + fileBrowser.classList.toggle("images-only"); + previewImages.classList.toggle("is-active"); + }); + } + } + + if (fileBrowser && fileContextMenu) { + fileBrowser.addEventListener("contextmenu", (event) => { + const card = event.target.closest("[data-file-context]"); + if (!card) { + return; + } + + event.preventDefault(); + contextFile = { + previewURL: card.dataset.previewUrl, + viewURL: card.dataset.viewUrl, + downloadURL: card.dataset.downloadUrl, + fileName: card.dataset.fileName, + }; + showContextMenu(event.clientX, event.clientY); + }); + + fileContextMenu.addEventListener("click", async (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button || !contextFile) { + return; + } + + const shouldHide = await runContextAction(button.dataset.contextAction, contextFile); + if (shouldHide !== false) { + hideContextMenu(); + } + }); + + document.addEventListener("click", (event) => { + if (!fileContextMenu.contains(event.target)) { + hideContextMenu(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideContextMenu(); + } + }); + + document.addEventListener("mousemove", (event) => { + if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) { + return; + } + hideContextMenu(); + }); + + window.addEventListener("resize", hideContextMenu); + window.addEventListener("scroll", hideContextMenu, true); + } + + if (previewActions.length > 0) { + previewActions.forEach((button) => { + button.addEventListener("click", async (event) => { + if (!event.ctrlKey && !ctrlCopyMode) { + return; + } + + event.preventDefault(); + await copyPreviewLink(button); + }); + }); + + window.addEventListener("keydown", (event) => { + if (event.key === "Control") { + setPreviewCopyMode(true); + } + }); + + window.addEventListener("keyup", (event) => { + if (event.key === "Control") { + setPreviewCopyMode(false); + } + }); + + window.addEventListener("blur", () => setPreviewCopyMode(false)); + } + + async function copyPreviewLink(button) { + await window.Warpbox.writeClipboard(button.href); + const label = button.querySelector("[data-preview-label]"); + if (!label) { + return; + } + + label.textContent = "Copied"; + setTimeout(() => { + label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View"; + }, 1200); + } + + function setPreviewCopyMode(enabled) { + ctrlCopyMode = enabled; + previewActions.forEach((button) => { + const label = button.querySelector("[data-preview-label]"); + const viewIcon = button.querySelector("[data-preview-view-icon]"); + const copyIcon = button.querySelector("[data-preview-copy-icon]"); + if (label) { + label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View"; + } + if (viewIcon) { + viewIcon.hidden = enabled; + } + if (copyIcon) { + copyIcon.hidden = !enabled; + } + }); + } + + async function runContextAction(action, file) { + if (action === "preview") { + window.Warpbox.openInNewTab(file.previewURL); + return true; + } + if (action === "view") { + window.Warpbox.openInNewTab(file.viewURL); + return true; + } + if (action === "copy-preview") { + await window.Warpbox.writeClipboard(file.previewURL); + return true; + } + if (action === "copy-download") { + await window.Warpbox.writeClipboard(file.downloadURL); + return true; + } + if (action === "download") { + window.Warpbox.openInNewTab(file.downloadURL); + } + return true; + } + + function showContextMenu(x, y) { + fileContextMenu.hidden = false; + fileContextMenu.style.left = "0px"; + fileContextMenu.style.top = "0px"; + + const rect = fileContextMenu.getBoundingClientRect(); + const margin = 8; + const left = Math.min(x, window.innerWidth - rect.width - margin); + const top = Math.min(y, window.innerHeight - rect.height - margin); + fileContextMenu.style.left = `${Math.max(margin, left)}px`; + fileContextMenu.style.top = `${Math.max(margin, top)}px`; + } + + function hideContextMenu() { + if (!fileContextMenu || fileContextMenu.hidden) { + return; + } + fileContextMenu.hidden = true; + contextFile = null; + } + + function isPointerNearContextMenu(x, y) { + const rect = fileContextMenu.getBoundingClientRect(); + return x >= rect.left - contextMenuCloseDistance && + x <= rect.right + contextMenuCloseDistance && + y >= rect.top - contextMenuCloseDistance && + y <= rect.bottom + contextMenuCloseDistance; + } +})(); diff --git a/backend/static/js/20-storage-admin.js b/backend/static/js/20-storage-admin.js new file mode 100644 index 0000000..838f35e --- /dev/null +++ b/backend/static/js/20-storage-admin.js @@ -0,0 +1,125 @@ +(function () { + const storageProviderSelects = document.querySelectorAll("[data-storage-provider]"); + + function syncStorageProvider(select) { + const formScope = select.closest("form"); + if (!formScope) { + return; + } + const provider = select.value; + const isContabo = provider === "contabo"; + formScope.querySelectorAll("[data-provider-fields]").forEach((group) => { + const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/); + const active = providers.includes(provider); + group.hidden = !active; + group.querySelectorAll("input, select, textarea").forEach((input) => { + input.disabled = !active; + }); + }); + const tls = formScope.querySelector('input[name="use_ssl"]'); + const pathStyle = formScope.querySelector('input[name="path_style"]'); + if (tls) { + tls.checked = isContabo || tls.checked; + tls.disabled = isContabo; + } + if (pathStyle) { + pathStyle.checked = isContabo || pathStyle.checked; + pathStyle.disabled = isContabo; + } + } + + storageProviderSelects.forEach((select) => { + select.addEventListener("change", () => syncStorageProvider(select)); + syncStorageProvider(select); + }); + + document.querySelectorAll(".storage-edit-trigger").forEach((button) => { + button.addEventListener("click", () => { + const card = button.closest(".storage-card"); + if (!card) { + return; + } + card.classList.add("is-editing"); + const providerSelect = card.querySelector("[data-storage-provider]"); + if (providerSelect) { + syncStorageProvider(providerSelect); + } + }); + }); + + document.querySelectorAll(".storage-cancel-trigger").forEach((button) => { + button.addEventListener("click", () => { + const card = button.closest(".storage-card"); + if (!card) { + return; + } + const form = card.querySelector("form"); + if (form) { + form.reset(); + } + card.classList.remove("is-editing"); + }); + }); + + const storageAddTrigger = document.querySelector(".storage-add-trigger"); + const storageTypePicker = document.querySelector(".storage-type-picker"); + const storageNewCard = document.querySelector(".storage-new-card"); + + const providerLabels = { + s3: "S3 bucket", + contabo: "Contabo Object Storage", + sftp: "SFTP", + smb: "Samba", + webdav: "WebDAV", + }; + + if (storageAddTrigger && storageTypePicker) { + storageAddTrigger.addEventListener("click", () => { + storageTypePicker.hidden = !storageTypePicker.hidden; + if (storageNewCard && !storageTypePicker.hidden) { + storageNewCard.hidden = true; + } + }); + + storageTypePicker.querySelectorAll(".storage-type-option").forEach((option) => { + option.addEventListener("click", () => { + const provider = option.dataset.provider; + if (!storageNewCard) { + return; + } + + const providerSelect = storageNewCard.querySelector("[data-storage-provider]"); + if (providerSelect) { + providerSelect.value = provider; + syncStorageProvider(providerSelect); + } + + const typeBadge = storageNewCard.querySelector(".storage-new-type-badge"); + if (typeBadge) { + typeBadge.textContent = providerLabels[provider] || provider; + } + + const iconEl = storageNewCard.querySelector(".storage-new-icon"); + const optIcon = option.querySelector("svg"); + if (iconEl && optIcon) { + iconEl.innerHTML = optIcon.outerHTML; + } + + storageTypePicker.hidden = true; + storageNewCard.hidden = false; + }); + }); + } + + if (storageNewCard) { + const cancelBtn = storageNewCard.querySelector(".storage-new-cancel"); + if (cancelBtn) { + cancelBtn.addEventListener("click", () => { + storageNewCard.hidden = true; + if (storageTypePicker) { + storageTypePicker.hidden = true; + } + }); + } + } +})(); diff --git a/backend/static/js/30-token-copy.js b/backend/static/js/30-token-copy.js new file mode 100644 index 0000000..606efab --- /dev/null +++ b/backend/static/js/30-token-copy.js @@ -0,0 +1,14 @@ +(function () { + const tokenCopyBtn = document.querySelector("[data-token-copy]"); + if (!tokenCopyBtn) { + return; + } + + tokenCopyBtn.addEventListener("click", () => { + const valueEl = document.querySelector("[data-token-value]"); + if (!valueEl) { + return; + } + window.Warpbox.copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied"); + }); +})(); diff --git a/backend/static/js/40-upload.js b/backend/static/js/40-upload.js new file mode 100644 index 0000000..db650fe --- /dev/null +++ b/backend/static/js/40-upload.js @@ -0,0 +1,252 @@ +(function () { + const form = document.querySelector("#upload-form"); + const dropZone = document.querySelector(".drop-zone"); + const fileInput = document.querySelector("#file-input"); + const fileSummary = document.querySelector("#file-summary"); + const progress = document.querySelector("#upload-progress"); + const uploadStatus = document.querySelector("#upload-status"); + const result = document.querySelector("#upload-result"); + const resultMeta = document.querySelector("#result-meta"); + const resultList = document.querySelector("#result-list"); + const uploadQueue = document.querySelector("#upload-queue"); + const totalProgressBar = document.querySelector("#total-progress-bar"); + const copyURL = document.querySelector("#copy-url"); + const openBox = document.querySelector("#open-box"); + const manageLink = document.querySelector("#manage-link"); + + if (!form || !dropZone || !fileInput) { + return; + } + + let latestBoxURL = ""; + let selectedFiles = []; + + ["dragenter", "dragover"].forEach((eventName) => { + dropZone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropZone.classList.add("is-dragging"); + }); + }); + + ["dragleave", "drop"].forEach((eventName) => { + dropZone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropZone.classList.remove("is-dragging"); + }); + }); + + dropZone.addEventListener("drop", (event) => { + if (event.dataTransfer && event.dataTransfer.files.length > 0) { + fileInput.files = event.dataTransfer.files; + updateSelectedState(event.dataTransfer.files); + } + }); + + fileInput.addEventListener("change", () => updateSelectedState(fileInput.files)); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!fileInput.files || fileInput.files.length === 0) { + updateStatus("Choose at least one file first."); + return; + } + + const submit = form.querySelector("button[type='submit']"); + const formData = new FormData(form); + selectedFiles = Array.from(fileInput.files); + renderQueue(selectedFiles, "queued"); + setLoading(true, submit); + + try { + const payload = await uploadWithProgress(form.action, formData, selectedFiles); + renderResult(payload); + form.reset(); + updateSelectedState([]); + } catch (error) { + updateStatus(error.message || "Upload failed"); + } finally { + setLoading(false, submit); + } + }); + + if (copyURL) { + copyURL.addEventListener("click", () => { + window.Warpbox.copyText(latestBoxURL, copyURL, "Copied"); + }); + } + + function updateSelectedState(files) { + selectedFiles = Array.from(files || []); + const count = selectedFiles.length || 0; + const title = dropZone.querySelector(".drop-title"); + if (title) { + title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`; + } + if (fileSummary) { + fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; + } + if (count > 0) { + renderQueue(selectedFiles, "queued"); + } else if (uploadQueue) { + uploadQueue.hidden = true; + uploadQueue.replaceChildren(); + } + } + + function setLoading(isLoading, submit) { + if (progress) { + progress.hidden = !isLoading; + } + if (submit) { + submit.disabled = isLoading; + submit.textContent = isLoading ? "Uploading..." : "Upload files"; + } + updateStatus(isLoading ? "Transferring files..." : ""); + setTotalProgress(isLoading ? 0 : 100); + } + + function updateStatus(message) { + if (uploadStatus) { + uploadStatus.textContent = message; + } + } + + function renderResult(payload) { + if (!result || !resultList || !resultMeta || !openBox) { + return; + } + + latestBoxURL = payload.boxUrl; + result.hidden = false; + openBox.href = payload.boxUrl; + resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`; + if (manageLink) { + const anchor = manageLink.querySelector("a"); + manageLink.hidden = !payload.manageUrl; + if (anchor && payload.manageUrl) { + anchor.href = payload.manageUrl; + } + } + + resultList.replaceChildren(); + payload.files.forEach((file) => { + resultList.append(createFileRow({ + name: file.name, + meta: `${file.size} · ${file.url}`, + progress: 100, + status: "complete", + })); + }); + } + + function uploadWithProgress(url, formData, files) { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open("POST", url); + request.setRequestHeader("Accept", "application/json"); + + request.upload.addEventListener("progress", (event) => { + if (!event.lengthComputable) { + updateStatus("Uploading..."); + return; + } + const percent = Math.round((event.loaded / event.total) * 100); + updateStatus(`${percent}%`); + setTotalProgress(percent); + setFileProgress(files, percent); + }); + + request.addEventListener("load", () => { + let payload = {}; + try { + payload = JSON.parse(request.responseText || "{}"); + } catch (error) { + reject(new Error("Upload response could not be read")); + return; + } + if (request.status < 200 || request.status >= 300) { + reject(new Error(payload.error || "Upload failed")); + return; + } + setTotalProgress(100); + setFileProgress(files, 100); + resolve(payload); + }); + + request.addEventListener("error", () => reject(new Error("Network error during upload"))); + request.addEventListener("abort", () => reject(new Error("Upload aborted"))); + request.send(formData); + }); + } + + function renderQueue(files, status) { + if (!uploadQueue) { + return; + } + uploadQueue.hidden = files.length === 0; + uploadQueue.replaceChildren(); + files.forEach((file) => { + uploadQueue.append(createFileRow({ + name: file.name, + meta: window.Warpbox.formatBytes(file.size), + progress: status === "queued" ? 0 : 100, + status, + })); + }); + } + + function createFileRow(file) { + const row = document.createElement("div"); + row.className = "result-item upload-file-row"; + row.dataset.fileName = file.name; + + const body = document.createElement("span"); + const name = document.createElement("strong"); + name.className = "file-name"; + name.textContent = file.name; + name.title = file.name; + const meta = document.createElement("code"); + meta.textContent = file.meta; + body.append(name, meta); + + const side = document.createElement("div"); + side.className = "file-progress-side"; + const percent = document.createElement("span"); + percent.className = "file-progress-percent"; + percent.textContent = `${file.progress}%`; + const bar = document.createElement("div"); + bar.className = "progress file-progress"; + const fill = document.createElement("span"); + fill.style.transform = `scaleX(${file.progress / 100})`; + bar.append(fill); + side.append(percent, bar); + + row.append(body, side); + return row; + } + + function setTotalProgress(percent) { + if (totalProgressBar) { + totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`; + } + } + + function setFileProgress(files, totalPercent) { + if (!uploadQueue) { + return; + } + const count = files.length || 1; + const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count; + uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => { + const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100))); + const percent = row.querySelector(".file-progress-percent"); + const fill = row.querySelector(".file-progress span"); + if (percent) { + percent.textContent = `${progress}%`; + } + if (fill) { + fill.style.transform = `scaleX(${progress / 100})`; + } + }); + } +})(); diff --git a/backend/static/js/app.js b/backend/static/js/app.js deleted file mode 100644 index 54c757b..0000000 --- a/backend/static/js/app.js +++ /dev/null @@ -1,636 +0,0 @@ -(function () { - const form = document.querySelector("#upload-form"); - const dropZone = document.querySelector(".drop-zone"); - const fileInput = document.querySelector("#file-input"); - const fileSummary = document.querySelector("#file-summary"); - const progress = document.querySelector("#upload-progress"); - const uploadStatus = document.querySelector("#upload-status"); - const result = document.querySelector("#upload-result"); - const resultMeta = document.querySelector("#result-meta"); - const resultList = document.querySelector("#result-list"); - const uploadQueue = document.querySelector("#upload-queue"); - const totalProgressBar = document.querySelector("#total-progress-bar"); - const copyURL = document.querySelector("#copy-url"); - const openBox = document.querySelector("#open-box"); - const manageLink = document.querySelector("#manage-link"); - const fileBrowser = document.querySelector("[data-file-browser]"); - const viewButtons = document.querySelectorAll("[data-view-button]"); - const previewImages = document.querySelector("[data-preview-images]"); - const previewActions = document.querySelectorAll("[data-preview-action]"); - const fileContextMenu = document.querySelector("[data-file-context-menu]"); - const storageProviderSelects = document.querySelectorAll("[data-storage-provider]"); - let ctrlCopyMode = false; - let contextFile = null; - const contextMenuCloseDistance = 80; - - if (fileBrowser) { - viewButtons.forEach((button) => { - button.addEventListener("click", () => { - const view = button.getAttribute("data-view-button"); - fileBrowser.classList.toggle("is-list", view === "list"); - fileBrowser.classList.toggle("is-thumbs", view === "thumbs"); - viewButtons.forEach((item) => item.classList.toggle("is-active", item === button)); - }); - }); - - if (previewImages) { - previewImages.addEventListener("click", () => { - fileBrowser.classList.toggle("images-only"); - previewImages.classList.toggle("is-active"); - }); - } - } - - if (fileBrowser && fileContextMenu) { - fileBrowser.addEventListener("contextmenu", (event) => { - const card = event.target.closest("[data-file-context]"); - if (!card) { - return; - } - - event.preventDefault(); - contextFile = { - previewURL: card.dataset.previewUrl, - viewURL: card.dataset.viewUrl, - downloadURL: card.dataset.downloadUrl, - fileName: card.dataset.fileName, - }; - showContextMenu(event.clientX, event.clientY); - }); - - fileContextMenu.addEventListener("click", async (event) => { - const button = event.target.closest("[data-context-action]"); - if (!button || !contextFile) { - return; - } - - const shouldHide = await runContextAction(button.dataset.contextAction, contextFile); - if (shouldHide !== false) { - hideContextMenu(); - } - }); - - document.addEventListener("click", (event) => { - if (!fileContextMenu.contains(event.target)) { - hideContextMenu(); - } - }); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - hideContextMenu(); - } - }); - - document.addEventListener("mousemove", (event) => { - if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) { - return; - } - hideContextMenu(); - }); - - window.addEventListener("resize", hideContextMenu); - window.addEventListener("scroll", hideContextMenu, true); - } - - if (previewActions.length > 0) { - previewActions.forEach((button) => { - button.addEventListener("click", async (event) => { - if (!event.ctrlKey && !ctrlCopyMode) { - return; - } - - event.preventDefault(); - await copyPreviewLink(button); - }); - }); - - window.addEventListener("keydown", (event) => { - if (event.key === "Control") { - setPreviewCopyMode(true); - } - }); - - window.addEventListener("keyup", (event) => { - if (event.key === "Control") { - setPreviewCopyMode(false); - } - }); - - window.addEventListener("blur", () => { - setPreviewCopyMode(false); - }); - } - - function syncStorageProvider(select) { - const formScope = select.closest("form"); - if (!formScope) { - return; - } - const provider = select.value; - const isContabo = provider === "contabo"; - formScope.querySelectorAll("[data-provider-fields]").forEach((group) => { - const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/); - const active = providers.includes(provider); - group.hidden = !active; - group.querySelectorAll("input, select, textarea").forEach((input) => { - input.disabled = !active; - }); - }); - const tls = formScope.querySelector('input[name="use_ssl"]'); - const pathStyle = formScope.querySelector('input[name="path_style"]'); - if (tls) { - tls.checked = isContabo || tls.checked; - tls.disabled = isContabo; - } - if (pathStyle) { - pathStyle.checked = isContabo || pathStyle.checked; - pathStyle.disabled = isContabo; - } - } - - if (storageProviderSelects.length > 0) { - storageProviderSelects.forEach((select) => { - select.addEventListener("change", () => syncStorageProvider(select)); - syncStorageProvider(select); - }); - } - - /* Storage card edit / cancel toggles */ - document.querySelectorAll(".storage-edit-trigger").forEach((btn) => { - btn.addEventListener("click", () => { - const card = btn.closest(".storage-card"); - if (!card) return; - card.classList.add("is-editing"); - const providerSelect = card.querySelector("[data-storage-provider]"); - if (providerSelect) { - syncStorageProvider(providerSelect); - } - }); - }); - - document.querySelectorAll(".storage-cancel-trigger").forEach((btn) => { - btn.addEventListener("click", () => { - const card = btn.closest(".storage-card"); - if (!card) return; - const form = card.querySelector("form"); - if (form) form.reset(); - card.classList.remove("is-editing"); - }); - }); - - /* Add storage: type picker */ - const storageAddTrigger = document.querySelector(".storage-add-trigger"); - const storageTypePicker = document.querySelector(".storage-type-picker"); - const storageNewCard = document.querySelector(".storage-new-card"); - - const providerLabels = { - s3: "S3 bucket", - contabo: "Contabo Object Storage", - sftp: "SFTP", - smb: "Samba", - webdav: "WebDAV", - }; - - const providerIconSVGs = { - s3: storageNewCard && storageNewCard.querySelector(".storage-new-icon") ? storageNewCard.querySelector(".storage-new-icon").innerHTML : "", - contabo: "", - sftp: "", - smb: "", - webdav: "", - }; - - if (storageAddTrigger && storageTypePicker) { - storageAddTrigger.addEventListener("click", () => { - storageTypePicker.hidden = !storageTypePicker.hidden; - if (storageNewCard && !storageTypePicker.hidden) { - storageNewCard.hidden = true; - } - }); - - storageTypePicker.querySelectorAll(".storage-type-option").forEach((opt) => { - opt.addEventListener("click", () => { - const provider = opt.dataset.provider; - if (!storageNewCard) return; - - const providerSelect = storageNewCard.querySelector("[data-storage-provider]"); - if (providerSelect) { - providerSelect.value = provider; - syncStorageProvider(providerSelect); - } - - const typeBadge = storageNewCard.querySelector(".storage-new-type-badge"); - if (typeBadge) typeBadge.textContent = providerLabels[provider] || provider; - - const iconEl = storageNewCard.querySelector(".storage-new-icon"); - const optIcon = opt.querySelector("svg"); - if (iconEl && optIcon) { - iconEl.innerHTML = optIcon.outerHTML; - } - - storageTypePicker.hidden = true; - storageNewCard.hidden = false; - }); - }); - } - - if (storageNewCard) { - const cancelBtn = storageNewCard.querySelector(".storage-new-cancel"); - if (cancelBtn) { - cancelBtn.addEventListener("click", () => { - storageNewCard.hidden = true; - if (storageTypePicker) storageTypePicker.hidden = true; - }); - } - } - - /* Access token: copy one-time secret */ - const tokenCopyBtn = document.querySelector("[data-token-copy]"); - if (tokenCopyBtn) { - tokenCopyBtn.addEventListener("click", () => { - const valueEl = document.querySelector("[data-token-value]"); - if (!valueEl) return; - copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied"); - }); - } - - if (!form || !dropZone || !fileInput) { - return; - } - - let latestBoxURL = ""; - let selectedFiles = []; - - ["dragenter", "dragover"].forEach((eventName) => { - dropZone.addEventListener(eventName, (event) => { - event.preventDefault(); - dropZone.classList.add("is-dragging"); - }); - }); - - ["dragleave", "drop"].forEach((eventName) => { - dropZone.addEventListener(eventName, (event) => { - event.preventDefault(); - dropZone.classList.remove("is-dragging"); - }); - }); - - dropZone.addEventListener("drop", (event) => { - if (event.dataTransfer && event.dataTransfer.files.length > 0) { - fileInput.files = event.dataTransfer.files; - updateSelectedState(event.dataTransfer.files); - } - }); - - fileInput.addEventListener("change", () => { - updateSelectedState(fileInput.files); - }); - - form.addEventListener("submit", async (event) => { - event.preventDefault(); - if (!fileInput.files || fileInput.files.length === 0) { - updateStatus("Choose at least one file first."); - return; - } - - const submit = form.querySelector("button[type='submit']"); - const formData = new FormData(form); - selectedFiles = Array.from(fileInput.files); - renderQueue(selectedFiles, "queued"); - setLoading(true, submit); - - try { - const payload = await uploadWithProgress(form.action, formData, selectedFiles); - renderResult(payload); - form.reset(); - updateSelectedState([]); - } catch (error) { - updateStatus(error.message || "Upload failed"); - } finally { - setLoading(false, submit); - } - }); - - if (copyURL) { - copyURL.addEventListener("click", () => { - copyText(latestBoxURL, copyURL, "Copied"); - }); - } - - function updateSelectedState(files) { - selectedFiles = Array.from(files || []); - const count = selectedFiles.length || 0; - const title = dropZone.querySelector(".drop-title"); - if (title) { - title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`; - } - if (fileSummary) { - fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; - } - if (count > 0) { - renderQueue(selectedFiles, "queued"); - } else if (uploadQueue) { - uploadQueue.hidden = true; - uploadQueue.replaceChildren(); - } - } - - function setLoading(isLoading, submit) { - if (progress) { - progress.hidden = !isLoading; - } - if (submit) { - submit.disabled = isLoading; - submit.textContent = isLoading ? "Uploading..." : "Upload files"; - } - updateStatus(isLoading ? "Transferring files..." : ""); - setTotalProgress(isLoading ? 0 : 100); - } - - function updateStatus(message) { - if (uploadStatus) { - uploadStatus.textContent = message; - } - } - - function renderResult(payload) { - if (!result || !resultList || !resultMeta || !openBox) { - return; - } - - latestBoxURL = payload.boxUrl; - result.hidden = false; - openBox.href = payload.boxUrl; - resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`; - if (manageLink) { - const anchor = manageLink.querySelector("a"); - manageLink.hidden = !payload.manageUrl; - if (anchor && payload.manageUrl) { - anchor.href = payload.manageUrl; - } - } - - resultList.replaceChildren(); - payload.files.forEach((file) => { - resultList.append(createFileRow({ - name: file.name, - meta: `${file.size} · ${file.url}`, - progress: 100, - status: "complete", - })); - }); - } - - function uploadWithProgress(url, formData, files) { - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest(); - request.open("POST", url); - request.setRequestHeader("Accept", "application/json"); - - request.upload.addEventListener("progress", (event) => { - if (!event.lengthComputable) { - updateStatus("Uploading..."); - return; - } - const percent = Math.round((event.loaded / event.total) * 100); - updateStatus(`${percent}%`); - setTotalProgress(percent); - setFileProgress(files, percent); - }); - - request.addEventListener("load", () => { - let payload = {}; - try { - payload = JSON.parse(request.responseText || "{}"); - } catch (error) { - reject(new Error("Upload response could not be read")); - return; - } - if (request.status < 200 || request.status >= 300) { - reject(new Error(payload.error || "Upload failed")); - return; - } - setTotalProgress(100); - setFileProgress(files, 100); - resolve(payload); - }); - - request.addEventListener("error", () => reject(new Error("Network error during upload"))); - request.addEventListener("abort", () => reject(new Error("Upload aborted"))); - request.send(formData); - }); - } - - function renderQueue(files, status) { - if (!uploadQueue) { - return; - } - uploadQueue.hidden = files.length === 0; - uploadQueue.replaceChildren(); - files.forEach((file) => { - uploadQueue.append(createFileRow({ - name: file.name, - meta: formatBytes(file.size), - progress: status === "queued" ? 0 : 100, - status, - })); - }); - } - - function createFileRow(file) { - const row = document.createElement("div"); - row.className = "result-item upload-file-row"; - row.dataset.fileName = file.name; - - const body = document.createElement("span"); - const name = document.createElement("strong"); - name.className = "file-name"; - name.textContent = file.name; - name.title = file.name; - const meta = document.createElement("code"); - meta.textContent = file.meta; - body.append(name, meta); - - const side = document.createElement("div"); - side.className = "file-progress-side"; - const percent = document.createElement("span"); - percent.className = "file-progress-percent"; - percent.textContent = `${file.progress}%`; - const bar = document.createElement("div"); - bar.className = "progress file-progress"; - const fill = document.createElement("span"); - fill.style.transform = `scaleX(${file.progress / 100})`; - bar.append(fill); - side.append(percent, bar); - - row.append(body, side); - return row; - } - - function setTotalProgress(percent) { - if (totalProgressBar) { - totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`; - } - } - - function setFileProgress(files, totalPercent) { - if (!uploadQueue) { - return; - } - const count = files.length || 1; - const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count; - uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => { - const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100))); - const percent = row.querySelector(".file-progress-percent"); - const fill = row.querySelector(".file-progress span"); - if (percent) { - percent.textContent = `${progress}%`; - } - if (fill) { - fill.style.transform = `scaleX(${progress / 100})`; - } - }); - } - - async function copyText(text, button, copiedLabel) { - if (!text) { - return; - } - await writeClipboard(text); - const previous = button.textContent; - button.textContent = copiedLabel; - setTimeout(() => { - button.textContent = previous; - }, 1400); - } - - async function copyPreviewLink(button) { - await writeClipboard(button.href); - const label = button.querySelector("[data-preview-label]"); - if (!label) { - return; - } - - label.textContent = "Copied"; - setTimeout(() => { - label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View"; - }, 1200); - } - - function setPreviewCopyMode(enabled) { - ctrlCopyMode = enabled; - previewActions.forEach((button) => { - const label = button.querySelector("[data-preview-label]"); - const viewIcon = button.querySelector("[data-preview-view-icon]"); - const copyIcon = button.querySelector("[data-preview-copy-icon]"); - if (label) { - label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View"; - } - if (viewIcon) { - viewIcon.hidden = enabled; - } - if (copyIcon) { - copyIcon.hidden = !enabled; - } - }); - } - - async function runContextAction(action, file) { - if (action === "preview") { - openInNewTab(file.previewURL); - return true; - } - if (action === "view") { - openInNewTab(file.viewURL); - return true; - } - if (action === "copy-preview") { - await writeClipboard(file.previewURL); - return true; - } - if (action === "copy-download") { - await writeClipboard(file.downloadURL); - return true; - } - if (action === "download") { - openInNewTab(file.downloadURL); - } - return true; - } - - function showContextMenu(x, y) { - fileContextMenu.hidden = false; - fileContextMenu.style.left = "0px"; - fileContextMenu.style.top = "0px"; - - const rect = fileContextMenu.getBoundingClientRect(); - const margin = 8; - const left = Math.min(x, window.innerWidth - rect.width - margin); - const top = Math.min(y, window.innerHeight - rect.height - margin); - fileContextMenu.style.left = `${Math.max(margin, left)}px`; - fileContextMenu.style.top = `${Math.max(margin, top)}px`; - } - - function hideContextMenu() { - if (!fileContextMenu || fileContextMenu.hidden) { - return; - } - fileContextMenu.hidden = true; - contextFile = null; - } - - function isPointerNearContextMenu(x, y) { - const rect = fileContextMenu.getBoundingClientRect(); - return x >= rect.left - contextMenuCloseDistance && - x <= rect.right + contextMenuCloseDistance && - y >= rect.top - contextMenuCloseDistance && - y <= rect.bottom + contextMenuCloseDistance; - } - - function openInNewTab(url) { - window.open(url, "_blank", "noopener,noreferrer"); - } - - async function writeClipboard(text) { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - return; - } - - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.setAttribute("readonly", ""); - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.append(textarea); - textarea.select(); - document.execCommand("copy"); - textarea.remove(); - } - - function formatDate(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); - } - - function formatBytes(bytes) { - if (bytes < 1024) { - return `${bytes} B`; - } - const units = ["KiB", "MiB", "GiB", "TiB"]; - let value = bytes / 1024; - let unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit += 1; - } - return `${value.toFixed(1)} ${units[unit]}`; - } -})(); diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index c96ddb7..7fd2389 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -15,8 +15,20 @@ {{if .ImageURL}}{{end}} {{if .ImageURL}}{{end}} - - + + + + + + + + + + + + + +