fix(auth): reject invalid bearer tokens instead of falling back
Modify the authentication handler to return an unauthorized error when an invalid or disabled bearer token is provided, rather than silently falling back to an anonymous request. This ensures that clients attempting to authenticate but failing (due to expired, malformed, or disabled tokens) are explicitly notified of the auth failure instead of proceeding anonymously. True anonymous requests without any Authorization header remain supported.
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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://")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]}`;
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -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");
|
||||
});
|
||||
})();
|
||||
@@ -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})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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]}`;
|
||||
}
|
||||
})();
|
||||
@@ -15,8 +15,20 @@
|
||||
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script defer src="/static/js/app.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/00-base.css">
|
||||
<link rel="stylesheet" href="/static/css/10-layout.css">
|
||||
<link rel="stylesheet" href="/static/css/20-upload.css">
|
||||
<link rel="stylesheet" href="/static/css/30-download.css">
|
||||
<link rel="stylesheet" href="/static/css/40-docs.css">
|
||||
<link rel="stylesheet" href="/static/css/50-admin.css">
|
||||
<link rel="stylesheet" href="/static/css/60-storage.css">
|
||||
<link rel="stylesheet" href="/static/css/70-tokens.css">
|
||||
<link rel="stylesheet" href="/static/css/90-responsive.css">
|
||||
<script defer src="/static/js/00-utils.js"></script>
|
||||
<script defer src="/static/js/10-file-browser.js"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js"></script>
|
||||
<script defer src="/static/js/30-token-copy.js"></script>
|
||||
<script defer src="/static/js/40-upload.js"></script>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
Reference in New Issue
Block a user