This commit is contained in:
parent
fa2be029a2
commit
724d70e58b
3339 changed files with 1075535 additions and 0 deletions
25
.forgejo/workflows/build.yml
Normal file
25
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Automated Container Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log into Local Registry
|
||||
run: |
|
||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Build and Push Image
|
||||
run: |
|
||||
# Force the entire image path string to lowercase dynamically
|
||||
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
docker build -t "$IMAGE_PATH" .
|
||||
docker push "$IMAGE_PATH"
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# IDE & Editor files
|
||||
.vscode/
|
||||
.cursor/
|
||||
.idea/
|
||||
*.swp
|
||||
*.xml
|
||||
|
||||
# Build outputs (if your Node app eventually compiles or bundles code)
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# Local application run data
|
||||
|
|
@ -0,0 +1 @@
|
|||
This project is aimed at being a self hosted version of google drive without any additional bloat.
|
||||
44
backend/Dockerfile
Normal file
44
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev sqlite-dev git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod ./
|
||||
# Copy everything and generate go.sum inside the container
|
||||
COPY . .
|
||||
RUN go mod tidy
|
||||
RUN go mod download
|
||||
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -tags sqlite_fts5 -ldflags="-s -w" -o /build/drive ./main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ffmpeg \
|
||||
sqlite \
|
||||
ca-certificates \
|
||||
tzdata
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -S drive && adduser -S drive -G drive
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/drive /app/drive
|
||||
|
||||
# Create default directories
|
||||
RUN mkdir -p /app/data /storage && \
|
||||
chown -R drive:drive /app /app/data /storage
|
||||
|
||||
USER drive
|
||||
|
||||
EXPOSE 5827
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:5827/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/drive"]
|
||||
57
backend/config/config.go
Normal file
57
backend/config/config.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DataDir string
|
||||
StorageDir string
|
||||
JWTSecret string
|
||||
JWTExpirySecs int
|
||||
DBPath string
|
||||
ThumbnailDir string
|
||||
TrashDir string
|
||||
VersionsDir string
|
||||
BackupDir string
|
||||
MaxLoginAttempts int
|
||||
LockoutSeconds int
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Port: getEnv("PORT", "5827"),
|
||||
DataDir: getEnv("DATA_DIR", "/app/data"),
|
||||
StorageDir: getEnv("STORAGE_DIR", "/storage"),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
JWTExpirySecs: getEnvInt("JWT_EXPIRY_SECS", 900), // 15 minutes
|
||||
MaxLoginAttempts: getEnvInt("MAX_LOGIN_ATTEMPTS", 5),
|
||||
LockoutSeconds: getEnvInt("LOCKOUT_SECONDS", 300), // 5 minutes
|
||||
}
|
||||
|
||||
cfg.DBPath = cfg.DataDir + "/drive.db"
|
||||
cfg.ThumbnailDir = cfg.DataDir + "/thumbnails"
|
||||
cfg.TrashDir = cfg.StorageDir + "/.trash"
|
||||
cfg.VersionsDir = cfg.StorageDir + "/.versions"
|
||||
cfg.BackupDir = cfg.StorageDir + "/.backups"
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
if intVal, err := strconv.Atoi(value); err == nil {
|
||||
return intVal
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
186
backend/database/database.go
Normal file
186
backend/database/database.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// New opens (or creates) the SQLite database, enables WAL mode, and runs migrations.
|
||||
func New(dbPath string) (*DB, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create db directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=on")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// Enforce WAL mode explicitly
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return nil, fmt.Errorf("set WAL mode: %w", err)
|
||||
}
|
||||
|
||||
wrapped := &DB{db}
|
||||
if err := wrapped.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
return wrapped, nil
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
migrations := []string{
|
||||
// Users table – single user now, designed for future multi-user
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
totp_secret TEXT DEFAULT '',
|
||||
totp_enabled INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Files metadata table
|
||||
`CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_dir INTEGER DEFAULT 0,
|
||||
size INTEGER DEFAULT 0,
|
||||
mime_type TEXT DEFAULT '',
|
||||
checksum TEXT DEFAULT '',
|
||||
is_pinned INTEGER DEFAULT 0,
|
||||
is_trashed INTEGER DEFAULT 0,
|
||||
trashed_at DATETIME,
|
||||
original_path TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Index for fast trash and pin queries
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_trashed ON files(is_trashed)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_pinned ON files(is_pinned)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)`,
|
||||
|
||||
// File versions table
|
||||
`CREATE TABLE IF NOT EXISTS file_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_path TEXT NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
version_path TEXT NOT NULL,
|
||||
size INTEGER DEFAULT 0,
|
||||
checksum TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Share links table
|
||||
`CREATE TABLE IF NOT EXISTS share_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
target_path TEXT NOT NULL,
|
||||
is_dir INTEGER DEFAULT 0,
|
||||
password_hash TEXT DEFAULT '',
|
||||
expires_at DATETIME,
|
||||
max_downloads INTEGER DEFAULT 0,
|
||||
download_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token)`,
|
||||
|
||||
// Audit log table
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT DEFAULT '',
|
||||
ip_address TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
|
||||
// Settings table (key-value)
|
||||
`CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT DEFAULT ''
|
||||
)`,
|
||||
|
||||
// FTS5 virtual table for file search
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS files_fts USING fts5(
|
||||
name,
|
||||
path,
|
||||
content=files,
|
||||
content_rowid=id
|
||||
)`,
|
||||
|
||||
// Triggers to keep FTS5 in sync with the files table
|
||||
`CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN
|
||||
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
|
||||
END`,
|
||||
|
||||
`CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN
|
||||
INSERT INTO files_fts(files_fts, rowid, name, path) VALUES('delete', old.id, old.name, old.path);
|
||||
END`,
|
||||
|
||||
`CREATE TRIGGER IF NOT EXISTS files_au AFTER UPDATE ON files BEGIN
|
||||
INSERT INTO files_fts(files_fts, rowid, name, path) VALUES('delete', old.id, old.name, old.path);
|
||||
INSERT INTO files_fts(rowid, name, path) VALUES (new.id, new.name, new.path);
|
||||
END`,
|
||||
|
||||
// Default settings
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('theme', 'dark')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_images', 'true')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('thumbnail_videos', 'true')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('max_versions', '5')`,
|
||||
`INSERT OR IGNORE INTO settings (key, value) VALUES ('trash_auto_purge_days', '30')`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if _, err := db.Exec(m); err != nil {
|
||||
return fmt.Errorf("migration error: %w\nSQL: %s", err, m)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSetupComplete checks if any user account exists.
|
||||
func (db *DB) IsSetupComplete() (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetSetting retrieves a setting value by key.
|
||||
func (db *DB) GetSetting(key string) (string, error) {
|
||||
var value string
|
||||
err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting.
|
||||
func (db *DB) SetSetting(key, value string) error {
|
||||
_, err := db.Exec("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddAuditLog appends an entry to the audit log.
|
||||
func (db *DB) AddAuditLog(action, details, ip string) error {
|
||||
_, err := db.Exec("INSERT INTO audit_log (action, details, ip_address) VALUES (?, ?, ?)", action, details, ip)
|
||||
return err
|
||||
}
|
||||
15
backend/go.mod
Normal file
15
backend/go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module git.elijahkuntz.com/Elijah/drive
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.52.6
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/mattn/go-sqlite3 v1.14.24
|
||||
github.com/pquerna/otp v1.4.0
|
||||
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/tus/tusd/v2 v2.7.1
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/net v0.33.0
|
||||
)
|
||||
151
backend/handlers/archives.go
Normal file
151
backend/handlers/archives.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"git.elijahkuntz.com/Elijah/drive/workers"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ArchiveHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Zip creates a zip archive of a folder.
|
||||
// POST /api/files/zip
|
||||
func (h *ArchiveHandler) Zip(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
DestPath string `json:"dest_path"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
info, err := os.Stat(srcFull)
|
||||
if err != nil || !info.IsDir() {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "source must be a valid directory"})
|
||||
}
|
||||
|
||||
// Default dest path if not provided
|
||||
if body.DestPath == "" {
|
||||
body.DestPath = filepath.ToSlash(filepath.Clean(body.SourcePath)) + ".zip"
|
||||
}
|
||||
|
||||
dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
task := workers.Tasks.CreateTask("zip", "Starting zip process...")
|
||||
|
||||
// Start async worker
|
||||
go func() {
|
||||
workers.ZipFolderAsync(task.ID, srcFull, dstFull)
|
||||
// Sync to DB when complete
|
||||
h.syncArchiveToDB(body.DestPath)
|
||||
}()
|
||||
|
||||
h.DB.AddAuditLog("zip_started", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Zip process started", "task_id": task.ID})
|
||||
}
|
||||
|
||||
// Unzip extracts a zip archive to a folder.
|
||||
// POST /api/files/unzip
|
||||
func (h *ArchiveHandler) Unzip(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
DestPath string `json:"dest_path"` // Folder to extract into
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
srcFull, err := resolveSafe(h.Config.StorageDir, body.SourcePath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Default dest folder
|
||||
if body.DestPath == "" {
|
||||
body.DestPath = strings.TrimSuffix(body.SourcePath, filepath.Ext(body.SourcePath))
|
||||
}
|
||||
|
||||
dstFull, err := resolveSafe(h.Config.StorageDir, body.DestPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
task := workers.Tasks.CreateTask("unzip", "Starting unzip process...")
|
||||
|
||||
// Start async worker
|
||||
go func() {
|
||||
workers.UnzipAsync(task.ID, srcFull, dstFull)
|
||||
// Trigger a DB resync for the extracted folder (lightweight for now)
|
||||
h.syncArchiveToDB(body.DestPath)
|
||||
}()
|
||||
|
||||
h.DB.AddAuditLog("unzip_started", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "Unzip process started", "task_id": task.ID})
|
||||
}
|
||||
|
||||
// Tasks returns the list of active background tasks.
|
||||
// GET /api/tasks
|
||||
func (h *ArchiveHandler) Tasks(c *fiber.Ctx) error {
|
||||
tasks := workers.Tasks.GetTasks()
|
||||
return c.JSON(fiber.Map{"tasks": tasks})
|
||||
}
|
||||
|
||||
// syncArchiveToDB records the output of the zip/unzip operation.
|
||||
func (h *ArchiveHandler) syncArchiveToDB(relPath string) {
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(relPath))
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
cleanPath := filepath.ToSlash(relPath)
|
||||
|
||||
// Just insert the top-level record, deeper sync isn't strictly needed
|
||||
// unless FTS is required immediately for all extracted contents.
|
||||
h.DB.Exec(`
|
||||
INSERT INTO files (path, name, size, is_dir)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, cleanPath, filepath.Base(cleanPath), info.Size(), info.IsDir())
|
||||
}
|
||||
|
||||
func resolveSafe(root, rel string) (string, error) {
|
||||
cleaned := filepath.Clean(rel)
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("path traversal detected")
|
||||
}
|
||||
|
||||
full := filepath.Join(root, cleaned)
|
||||
abs, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
absRoot, _ := filepath.Abs(root)
|
||||
if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("access denied")
|
||||
}
|
||||
|
||||
return abs, nil
|
||||
}
|
||||
315
backend/handlers/auth.go
Normal file
315
backend/handlers/auth.go
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"git.elijahkuntz.com/Elijah/drive/middleware"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
Guard *middleware.BruteForceGuard
|
||||
}
|
||||
|
||||
type SetupRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
TOTPCode string `json:"totp_code,omitempty"`
|
||||
}
|
||||
|
||||
// Setup handles the first-run account creation wizard.
|
||||
// POST /api/setup
|
||||
func (h *AuthHandler) Setup(c *fiber.Ctx) error {
|
||||
// Check if setup is already complete
|
||||
complete, err := h.DB.IsSetupComplete()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
if complete {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "setup already completed"})
|
||||
}
|
||||
|
||||
var req SetupRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
if len(req.Username) < 3 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "username must be at least 3 characters"})
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password must be at least 8 characters"})
|
||||
}
|
||||
|
||||
hash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
_, err = h.DB.Exec(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
req.Username, hash,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create user"})
|
||||
}
|
||||
|
||||
h.DB.AddAuditLog("setup_complete", fmt.Sprintf("User '%s' created", req.Username), c.IP())
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": "setup complete, please log in",
|
||||
})
|
||||
}
|
||||
|
||||
// CheckSetup returns whether initial setup has been completed.
|
||||
// GET /api/setup/status
|
||||
func (h *AuthHandler) CheckSetup(c *fiber.Ctx) error {
|
||||
complete, err := h.DB.IsSetupComplete()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
return c.JSON(fiber.Map{"setup_complete": complete})
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns JWT tokens.
|
||||
// POST /api/auth/login
|
||||
func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
||||
var req LoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
var id int
|
||||
var passwordHash, totpSecret string
|
||||
var totpEnabled bool
|
||||
|
||||
err := h.DB.QueryRow(
|
||||
"SELECT id, password_hash, totp_secret, totp_enabled FROM users WHERE username = ?",
|
||||
req.Username,
|
||||
).Scan(&id, &passwordHash, &totpSecret, &totpEnabled)
|
||||
|
||||
if err != nil {
|
||||
h.Guard.RecordFailure(c.IP())
|
||||
h.DB.AddAuditLog("login_failed", fmt.Sprintf("Unknown user '%s'", req.Username), c.IP())
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
if !verifyPassword(req.Password, passwordHash) {
|
||||
h.Guard.RecordFailure(c.IP())
|
||||
h.DB.AddAuditLog("login_failed", fmt.Sprintf("Bad password for '%s'", req.Username), c.IP())
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
// Check TOTP if enabled
|
||||
if totpEnabled {
|
||||
if req.TOTPCode == "" {
|
||||
return c.Status(fiber.StatusPreconditionRequired).JSON(fiber.Map{
|
||||
"error": "2fa_required",
|
||||
"message": "Two-factor authentication code required",
|
||||
"totp_required": true,
|
||||
})
|
||||
}
|
||||
if !totp.Validate(req.TOTPCode, totpSecret) {
|
||||
h.Guard.RecordFailure(c.IP())
|
||||
h.DB.AddAuditLog("login_failed", fmt.Sprintf("Bad TOTP for '%s'", req.Username), c.IP())
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid 2FA code"})
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := middleware.GenerateAccessToken(id, req.Username, h.Config.JWTSecret, h.Config.JWTExpirySecs)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
refreshToken, err := middleware.GenerateRefreshToken(id, req.Username, h.Config.JWTSecret)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate refresh token"})
|
||||
}
|
||||
|
||||
h.Guard.RecordSuccess(c.IP())
|
||||
h.DB.AddAuditLog("login_success", fmt.Sprintf("User '%s' logged in", req.Username), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"expires_in": h.Config.JWTExpirySecs,
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh issues a new access token from a valid refresh token.
|
||||
// POST /api/auth/refresh
|
||||
func (h *AuthHandler) Refresh(c *fiber.Ctx) error {
|
||||
// The auth middleware already validated the token
|
||||
userID := c.Locals("userID")
|
||||
username := c.Locals("username")
|
||||
|
||||
accessToken, err := middleware.GenerateAccessToken(
|
||||
int(userID.(float64)),
|
||||
username.(string),
|
||||
h.Config.JWTSecret,
|
||||
h.Config.JWTExpirySecs,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate token"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"access_token": accessToken,
|
||||
"expires_in": h.Config.JWTExpirySecs,
|
||||
})
|
||||
}
|
||||
|
||||
// Enable2FA generates a TOTP secret and returns the provisioning URI.
|
||||
// POST /api/auth/2fa/enable
|
||||
func (h *AuthHandler) Enable2FA(c *fiber.Ctx) error {
|
||||
username := c.Locals("username").(string)
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Drive",
|
||||
AccountName: username,
|
||||
})
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate TOTP secret"})
|
||||
}
|
||||
|
||||
// Store the secret but don't enable 2FA until confirmed
|
||||
_, err = h.DB.Exec("UPDATE users SET totp_secret = ? WHERE username = ?", key.Secret(), username)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save TOTP secret"})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"secret": key.Secret(),
|
||||
"url": key.URL(),
|
||||
})
|
||||
}
|
||||
|
||||
// Confirm2FA verifies a TOTP code and enables 2FA.
|
||||
// POST /api/auth/2fa/confirm
|
||||
func (h *AuthHandler) Confirm2FA(c *fiber.Ctx) error {
|
||||
username := c.Locals("username").(string)
|
||||
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
||||
}
|
||||
|
||||
var secret string
|
||||
err := h.DB.QueryRow("SELECT totp_secret FROM users WHERE username = ?", username).Scan(&secret)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
|
||||
if !totp.Validate(body.Code, secret) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid TOTP code"})
|
||||
}
|
||||
|
||||
_, err = h.DB.Exec("UPDATE users SET totp_enabled = 1 WHERE username = ?", username)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to enable 2FA"})
|
||||
}
|
||||
|
||||
h.DB.AddAuditLog("2fa_enabled", fmt.Sprintf("User '%s' enabled 2FA", username), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "2FA enabled successfully"})
|
||||
}
|
||||
|
||||
// Disable2FA turns off two-factor authentication.
|
||||
// POST /api/auth/2fa/disable
|
||||
func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error {
|
||||
username := c.Locals("username").(string)
|
||||
|
||||
_, err := h.DB.Exec("UPDATE users SET totp_enabled = 0, totp_secret = '' WHERE username = ?", username)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to disable 2FA"})
|
||||
}
|
||||
|
||||
h.DB.AddAuditLog("2fa_disabled", fmt.Sprintf("User '%s' disabled 2FA", username), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "2FA disabled successfully"})
|
||||
}
|
||||
|
||||
// --- Password Hashing with Argon2 ---
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
|
||||
encoded := fmt.Sprintf("%s$%s",
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
func verifyPassword(password, encoded string) bool {
|
||||
parts := splitN(encoded, "$", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
|
||||
|
||||
if len(hash) != len(expectedHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
var diff byte
|
||||
for i := range hash {
|
||||
diff |= hash[i] ^ expectedHash[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
func splitN(s, sep string, n int) []string {
|
||||
result := make([]string, 0, n)
|
||||
for i := 0; i < n-1; i++ {
|
||||
idx := indexOf(s, sep)
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
result = append(result, s[:idx])
|
||||
s = s[idx+len(sep):]
|
||||
}
|
||||
result = append(result, s)
|
||||
return result
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
807
backend/handlers/files.go
Normal file
807
backend/handlers/files.go
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"git.elijahkuntz.com/Elijah/drive/workers"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
type FSHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
IsTrashed bool `json:"is_trashed"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// ListDirectory returns the contents of a directory.
|
||||
// GET /api/files/*
|
||||
func (h *FSHandler) ListDirectory(c *fiber.Ctx) error {
|
||||
resolvedPath := c.Locals("resolvedPath").(string)
|
||||
|
||||
info, err := os.Stat(resolvedPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "path not found"})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to stat path"})
|
||||
}
|
||||
|
||||
// If it's a file, return its info directly
|
||||
if !info.IsDir() {
|
||||
return h.getFileInfo(c, resolvedPath)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(resolvedPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read directory"})
|
||||
}
|
||||
|
||||
relativePath := c.Locals("relativePath").(string)
|
||||
files := make([]FileInfo, 0, len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
// Skip hidden system directories
|
||||
if entry.Name() == ".versions" || entry.Name() == ".trash" || entry.Name() == ".backups" {
|
||||
continue
|
||||
}
|
||||
|
||||
entryInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
entryPath := filepath.Join(relativePath, entry.Name())
|
||||
mimeType := ""
|
||||
if !entry.IsDir() {
|
||||
mimeType = mime.TypeByExtension(filepath.Ext(entry.Name()))
|
||||
}
|
||||
|
||||
fi := FileInfo{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.ToSlash(entryPath),
|
||||
IsDir: entry.IsDir(),
|
||||
Size: entryInfo.Size(),
|
||||
MimeType: mimeType,
|
||||
ModTime: entryInfo.ModTime(),
|
||||
}
|
||||
|
||||
// Enrich with DB metadata (pinned status, etc.)
|
||||
var isPinned, isTrashed int
|
||||
err = h.DB.QueryRow(
|
||||
"SELECT is_pinned, is_trashed FROM files WHERE path = ?",
|
||||
filepath.ToSlash(entryPath),
|
||||
).Scan(&isPinned, &isTrashed)
|
||||
if err == nil {
|
||||
fi.IsPinned = isPinned == 1
|
||||
fi.IsTrashed = isTrashed == 1
|
||||
}
|
||||
|
||||
// Don't show trashed items in normal listings
|
||||
if fi.IsTrashed {
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, fi)
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].IsDir != files[j].IsDir {
|
||||
return files[i].IsDir
|
||||
}
|
||||
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"path": filepath.ToSlash(relativePath),
|
||||
"files": files,
|
||||
"count": len(files),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FSHandler) getFileInfo(c *fiber.Ctx, absPath string) error {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
||||
}
|
||||
|
||||
relativePath := c.Locals("relativePath").(string)
|
||||
|
||||
fi := FileInfo{
|
||||
Name: info.Name(),
|
||||
Path: filepath.ToSlash(relativePath),
|
||||
IsDir: info.IsDir(),
|
||||
Size: info.Size(),
|
||||
MimeType: mime.TypeByExtension(filepath.Ext(info.Name())),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
|
||||
var isPinned int
|
||||
var checksum string
|
||||
err = h.DB.QueryRow(
|
||||
"SELECT is_pinned, checksum FROM files WHERE path = ?",
|
||||
filepath.ToSlash(relativePath),
|
||||
).Scan(&isPinned, &checksum)
|
||||
if err == nil {
|
||||
fi.IsPinned = isPinned == 1
|
||||
fi.Checksum = checksum
|
||||
}
|
||||
|
||||
return c.JSON(fi)
|
||||
}
|
||||
|
||||
// CreateFolder creates a new directory.
|
||||
// POST /api/files/mkdir
|
||||
func (h *FSHandler) CreateFolder(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
if body.Path == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"})
|
||||
}
|
||||
|
||||
// Validate path within jail
|
||||
fullPath, err := h.resolveSafe(body.Path)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(fullPath, 0755); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create folder"})
|
||||
}
|
||||
|
||||
// Record in DB
|
||||
cleanPath := filepath.ToSlash(body.Path)
|
||||
h.DB.Exec(
|
||||
"INSERT OR IGNORE INTO files (path, name, is_dir) VALUES (?, ?, 1)",
|
||||
cleanPath, filepath.Base(cleanPath),
|
||||
)
|
||||
|
||||
h.DB.AddAuditLog("folder_created", cleanPath, c.IP())
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": "folder created",
|
||||
"path": cleanPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Rename renames a file or folder.
|
||||
// POST /api/files/rename
|
||||
func (h *FSHandler) Rename(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
OldPath string `json:"old_path"`
|
||||
NewName string `json:"new_name"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
oldFull, err := h.resolveSafe(body.OldPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
newPath := filepath.Join(filepath.Dir(body.OldPath), body.NewName)
|
||||
newFull, err := h.resolveSafe(newPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
if err := os.Rename(oldFull, newFull); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rename"})
|
||||
}
|
||||
|
||||
// Update DB
|
||||
h.DB.Exec(
|
||||
"UPDATE files SET path = ?, name = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
|
||||
filepath.ToSlash(newPath), body.NewName, filepath.ToSlash(body.OldPath),
|
||||
)
|
||||
|
||||
h.DB.AddAuditLog("renamed", fmt.Sprintf("%s -> %s", body.OldPath, newPath), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "renamed successfully", "new_path": filepath.ToSlash(newPath)})
|
||||
}
|
||||
|
||||
// Move moves a file or folder to a new location.
|
||||
// POST /api/files/move
|
||||
func (h *FSHandler) Move(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
DestPath string `json:"dest_path"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
srcFull, err := h.resolveSafe(body.SourcePath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
dstFull, err := h.resolveSafe(body.DestPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// If destination is a directory, move into it
|
||||
if info, err := os.Stat(dstFull); err == nil && info.IsDir() {
|
||||
dstFull = filepath.Join(dstFull, filepath.Base(srcFull))
|
||||
body.DestPath = filepath.Join(body.DestPath, filepath.Base(body.SourcePath))
|
||||
}
|
||||
|
||||
if err := os.Rename(srcFull, dstFull); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move"})
|
||||
}
|
||||
|
||||
h.DB.Exec(
|
||||
"UPDATE files SET path = ?, updated_at = CURRENT_TIMESTAMP WHERE path = ?",
|
||||
filepath.ToSlash(body.DestPath), filepath.ToSlash(body.SourcePath),
|
||||
)
|
||||
|
||||
h.DB.AddAuditLog("moved", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "moved successfully"})
|
||||
}
|
||||
|
||||
// Copy copies a file or folder.
|
||||
// POST /api/files/copy
|
||||
func (h *FSHandler) Copy(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
DestPath string `json:"dest_path"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
srcFull, err := h.resolveSafe(body.SourcePath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
dstFull, err := h.resolveSafe(body.DestPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
info, err := os.Stat(srcFull)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "source not found"})
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if err := copyDir(srcFull, dstFull); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy directory"})
|
||||
}
|
||||
} else {
|
||||
if err := copyFile(srcFull, dstFull); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to copy file"})
|
||||
}
|
||||
}
|
||||
|
||||
h.DB.AddAuditLog("copied", fmt.Sprintf("%s -> %s", body.SourcePath, body.DestPath), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "copied successfully"})
|
||||
}
|
||||
|
||||
// Delete soft-deletes a file (moves to trash).
|
||||
// DELETE /api/files/*
|
||||
func (h *FSHandler) Delete(c *fiber.Ctx) error {
|
||||
resolvedPath := c.Locals("resolvedPath").(string)
|
||||
relativePath := c.Locals("relativePath").(string)
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
||||
}
|
||||
|
||||
// Soft-delete: move to .trash directory
|
||||
trashPath := filepath.Join(h.Config.TrashDir, relativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(trashPath), 0755); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare trash"})
|
||||
}
|
||||
|
||||
if err := os.Rename(resolvedPath, trashPath); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to move to trash"})
|
||||
}
|
||||
|
||||
// Update DB
|
||||
cleanPath := filepath.ToSlash(relativePath)
|
||||
h.DB.Exec(
|
||||
`UPDATE files SET is_trashed = 1, trashed_at = CURRENT_TIMESTAMP, original_path = ? WHERE path = ?`,
|
||||
cleanPath, cleanPath,
|
||||
)
|
||||
|
||||
// Also insert if not tracked
|
||||
h.DB.Exec(
|
||||
`INSERT OR IGNORE INTO files (path, name, is_trashed, trashed_at, original_path) VALUES (?, ?, 1, CURRENT_TIMESTAMP, ?)`,
|
||||
cleanPath, filepath.Base(cleanPath), cleanPath,
|
||||
)
|
||||
|
||||
h.DB.AddAuditLog("deleted", cleanPath, c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "moved to trash"})
|
||||
}
|
||||
|
||||
// ListTrash lists all trashed items.
|
||||
// GET /api/trash
|
||||
func (h *FSHandler) ListTrash(c *fiber.Ctx) error {
|
||||
rows, err := h.DB.Query(
|
||||
"SELECT path, name, is_dir, size, original_path, trashed_at FROM files WHERE is_trashed = 1 ORDER BY trashed_at DESC",
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type TrashItem struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
OriginalPath string `json:"original_path"`
|
||||
TrashedAt time.Time `json:"trashed_at"`
|
||||
}
|
||||
|
||||
items := []TrashItem{}
|
||||
for rows.Next() {
|
||||
var item TrashItem
|
||||
var isDir int
|
||||
var trashedAt sql.NullTime
|
||||
if err := rows.Scan(&item.Path, &item.Name, &isDir, &item.Size, &item.OriginalPath, &trashedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
item.IsDir = isDir == 1
|
||||
if trashedAt.Valid {
|
||||
item.TrashedAt = trashedAt.Time
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"items": items, "count": len(items)})
|
||||
}
|
||||
|
||||
// RestoreFromTrash restores a file from the trash.
|
||||
// POST /api/trash/restore
|
||||
func (h *FSHandler) RestoreFromTrash(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
// Get original path from DB
|
||||
var originalPath string
|
||||
err := h.DB.QueryRow(
|
||||
"SELECT original_path FROM files WHERE path = ? AND is_trashed = 1",
|
||||
body.Path,
|
||||
).Scan(&originalPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "item not found in trash"})
|
||||
}
|
||||
|
||||
trashPath := filepath.Join(h.Config.TrashDir, filepath.FromSlash(body.Path))
|
||||
restorePath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(originalPath))
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(restorePath), 0755); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to prepare restore location"})
|
||||
}
|
||||
|
||||
if err := os.Rename(trashPath, restorePath); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to restore file"})
|
||||
}
|
||||
|
||||
h.DB.Exec(
|
||||
"UPDATE files SET is_trashed = 0, trashed_at = NULL WHERE path = ?",
|
||||
body.Path,
|
||||
)
|
||||
|
||||
h.DB.AddAuditLog("restored", fmt.Sprintf("Restored '%s' from trash", originalPath), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "restored successfully", "path": originalPath})
|
||||
}
|
||||
|
||||
// EmptyTrash permanently deletes all trashed items.
|
||||
// DELETE /api/trash
|
||||
func (h *FSHandler) EmptyTrash(c *fiber.Ctx) error {
|
||||
if err := os.RemoveAll(h.Config.TrashDir); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to empty trash"})
|
||||
}
|
||||
os.MkdirAll(h.Config.TrashDir, 0755)
|
||||
|
||||
h.DB.Exec("DELETE FROM files WHERE is_trashed = 1")
|
||||
|
||||
h.DB.AddAuditLog("trash_emptied", "All trashed items permanently deleted", c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{"message": "trash emptied"})
|
||||
}
|
||||
|
||||
// TogglePin pins or unpins a file/folder.
|
||||
// POST /api/files/pin
|
||||
func (h *FSHandler) TogglePin(c *fiber.Ctx) error {
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
cleanPath := filepath.ToSlash(body.Path)
|
||||
|
||||
// Ensure the file record exists
|
||||
h.DB.Exec(
|
||||
"INSERT OR IGNORE INTO files (path, name) VALUES (?, ?)",
|
||||
cleanPath, filepath.Base(cleanPath),
|
||||
)
|
||||
|
||||
// Toggle pin
|
||||
_, err := h.DB.Exec(
|
||||
"UPDATE files SET is_pinned = CASE WHEN is_pinned = 1 THEN 0 ELSE 1 END WHERE path = ?",
|
||||
cleanPath,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to toggle pin"})
|
||||
}
|
||||
|
||||
var isPinned int
|
||||
h.DB.QueryRow("SELECT is_pinned FROM files WHERE path = ?", cleanPath).Scan(&isPinned)
|
||||
|
||||
return c.JSON(fiber.Map{"pinned": isPinned == 1, "path": cleanPath})
|
||||
}
|
||||
|
||||
// ListPinned returns all pinned items.
|
||||
// GET /api/files/pinned
|
||||
func (h *FSHandler) ListPinned(c *fiber.Ctx) error {
|
||||
rows, err := h.DB.Query(
|
||||
"SELECT path, name, is_dir, size, mime_type FROM files WHERE is_pinned = 1 AND is_trashed = 0",
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []FileInfo{}
|
||||
for rows.Next() {
|
||||
var fi FileInfo
|
||||
var isDir int
|
||||
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType); err != nil {
|
||||
continue
|
||||
}
|
||||
fi.IsDir = isDir == 1
|
||||
fi.IsPinned = true
|
||||
items = append(items, fi)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"items": items, "count": len(items)})
|
||||
}
|
||||
|
||||
// Search performs an FTS5 full-text search on file names and paths.
|
||||
// GET /api/search?q=query
|
||||
func (h *FSHandler) Search(c *fiber.Ctx) error {
|
||||
query := c.Query("q", "")
|
||||
if query == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"})
|
||||
}
|
||||
|
||||
// Append wildcard for prefix matching
|
||||
ftsQuery := query + "*"
|
||||
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT f.path, f.name, f.is_dir, f.size, f.mime_type, f.is_pinned
|
||||
FROM files f
|
||||
JOIN files_fts fts ON f.id = fts.rowid
|
||||
WHERE files_fts MATCH ? AND f.is_trashed = 0
|
||||
ORDER BY rank
|
||||
LIMIT 50
|
||||
`, ftsQuery)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "search failed"})
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
results := []FileInfo{}
|
||||
for rows.Next() {
|
||||
var fi FileInfo
|
||||
var isDir, isPinned int
|
||||
if err := rows.Scan(&fi.Path, &fi.Name, &isDir, &fi.Size, &fi.MimeType, &isPinned); err != nil {
|
||||
continue
|
||||
}
|
||||
fi.IsDir = isDir == 1
|
||||
fi.IsPinned = isPinned == 1
|
||||
results = append(results, fi)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"results": results, "count": len(results)})
|
||||
}
|
||||
|
||||
// Download serves a file with full HTTP Range Request support.
|
||||
// GET /api/files/download/*
|
||||
func (h *FSHandler) Download(c *fiber.Ctx) error {
|
||||
resolvedPath := c.Locals("resolvedPath").(string)
|
||||
|
||||
info, err := os.Stat(resolvedPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
||||
}
|
||||
|
||||
// Verify checksum on download if stored
|
||||
relativePath := c.Locals("relativePath").(string)
|
||||
var storedChecksum string
|
||||
h.DB.QueryRow("SELECT checksum FROM files WHERE path = ?", filepath.ToSlash(relativePath)).Scan(&storedChecksum)
|
||||
|
||||
// Set appropriate headers for range requests
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Content-Type", mime.TypeByExtension(filepath.Ext(resolvedPath)))
|
||||
|
||||
// Use Fiber's built-in SendFile which supports Range requests
|
||||
return c.SendFile(resolvedPath)
|
||||
}
|
||||
|
||||
// Upload handles file upload and generates an XXHash checksum.
|
||||
// POST /api/files/upload
|
||||
func (h *FSHandler) Upload(c *fiber.Ctx) error {
|
||||
destPath := c.FormValue("path", "")
|
||||
if destPath == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "destination path is required"})
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
|
||||
}
|
||||
|
||||
fullDest, err := h.resolveSafe(destPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// If destination is a directory, save into it
|
||||
if info, err := os.Stat(fullDest); err == nil && info.IsDir() {
|
||||
fullDest = filepath.Join(fullDest, file.Filename)
|
||||
destPath = filepath.Join(destPath, file.Filename)
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(fullDest), 0755); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create directory"})
|
||||
}
|
||||
|
||||
// Version existing file if it exists
|
||||
CreateVersion(h.DB, h.Config, fullDest, filepath.ToSlash(destPath))
|
||||
|
||||
// Save the file
|
||||
if err := c.SaveFile(file, fullDest); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to save file"})
|
||||
}
|
||||
|
||||
// Generate XXHash checksum
|
||||
checksum, err := generateChecksum(fullDest)
|
||||
if err != nil {
|
||||
// File saved but checksum failed — log but don't fail the upload
|
||||
fmt.Printf("Warning: checksum generation failed for %s: %v\n", destPath, err)
|
||||
}
|
||||
|
||||
cleanPath := filepath.ToSlash(destPath)
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(file.Filename))
|
||||
|
||||
// Upsert file record
|
||||
h.DB.Exec(`
|
||||
INSERT INTO files (path, name, size, mime_type, checksum)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
mime_type = excluded.mime_type,
|
||||
checksum = excluded.checksum,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, cleanPath, file.Filename, file.Size, mimeType, checksum)
|
||||
|
||||
h.DB.AddAuditLog("uploaded", fmt.Sprintf("%s (%d bytes)", cleanPath, file.Size), c.IP())
|
||||
|
||||
// Queue thumbnail generation
|
||||
if workers.Instance != nil {
|
||||
workers.Instance.Enqueue(workers.ThumbnailJob{
|
||||
FilePath: fullDest,
|
||||
Checksum: checksum,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||
"message": "uploaded successfully",
|
||||
"path": cleanPath,
|
||||
"checksum": checksum,
|
||||
"size": file.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// StorageDashboard returns storage usage broken down by file type.
|
||||
// GET /api/storage/dashboard
|
||||
func (h *FSHandler) StorageDashboard(c *fiber.Ctx) error {
|
||||
type CategoryUsage struct {
|
||||
Category string `json:"category"`
|
||||
Size int64 `json:"size"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// Walk the storage directory to compute real usage
|
||||
categories := map[string]*CategoryUsage{
|
||||
"images": {Category: "images"},
|
||||
"videos": {Category: "videos"},
|
||||
"audio": {Category: "audio"},
|
||||
"documents": {Category: "documents"},
|
||||
"archives": {Category: "archives"},
|
||||
"other": {Category: "other"},
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
|
||||
filepath.Walk(h.Config.StorageDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// Skip system dirs
|
||||
rel, _ := filepath.Rel(h.Config.StorageDir, path)
|
||||
if strings.HasPrefix(rel, ".trash") || strings.HasPrefix(rel, ".versions") || strings.HasPrefix(rel, ".backups") {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(info.Name()))
|
||||
cat := categorizeFile(ext)
|
||||
categories[cat].Size += info.Size()
|
||||
categories[cat].Count++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
|
||||
result := make([]CategoryUsage, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
if cat.Count > 0 {
|
||||
result = append(result, *cat)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"total_size": totalSize,
|
||||
"categories": result,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func (h *FSHandler) resolveSafe(relativePath string) (string, error) {
|
||||
cleaned := filepath.Clean(relativePath)
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return "", fmt.Errorf("path traversal detected")
|
||||
}
|
||||
|
||||
full := filepath.Join(h.Config.StorageDir, cleaned)
|
||||
abs, err := filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
absRoot, _ := filepath.Abs(h.Config.StorageDir)
|
||||
if abs != absRoot && !strings.HasPrefix(abs, absRoot+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("access denied")
|
||||
}
|
||||
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func generateChecksum(filePath string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := xxhash.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%016x", h.Sum64()), nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
destFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, sourceFile)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(src, path)
|
||||
target := filepath.Join(dst, rel)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func categorizeFile(ext string) string {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp", ".ico", ".tiff":
|
||||
return "images"
|
||||
case ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v":
|
||||
return "videos"
|
||||
case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a":
|
||||
return "audio"
|
||||
case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".csv":
|
||||
return "documents"
|
||||
case ".zip", ".tar", ".gz", ".rar", ".7z", ".bz2":
|
||||
return "archives"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// ServeThumbnail serves a generated thumbnail image.
|
||||
// GET /api/files/thumbnail/:checksum
|
||||
func (h *FSHandler) ServeThumbnail(c *fiber.Ctx) error {
|
||||
checksum := c.Params("checksum")
|
||||
if checksum == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing checksum"})
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(h.Config.ThumbnailDir, checksum+".jpg")
|
||||
|
||||
if _, err := os.Stat(thumbPath); os.IsNotExist(err) {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
// Set caching headers for thumbnails (they don't change because checksums are immutable)
|
||||
c.Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
|
||||
return c.SendFile(thumbPath)
|
||||
}
|
||||
53
backend/handlers/health.go
Normal file
53
backend/handlers/health.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// Health returns the application health status including DB and storage availability.
|
||||
// GET /health
|
||||
func (h *HealthHandler) Health(c *fiber.Ctx) error {
|
||||
status := "healthy"
|
||||
dbOk := true
|
||||
storageOk := true
|
||||
|
||||
// Check database
|
||||
if err := h.DB.Ping(); err != nil {
|
||||
dbOk = false
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
// Check storage directory
|
||||
if _, err := os.Stat(h.Config.StorageDir); os.IsNotExist(err) {
|
||||
storageOk = false
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
// Check data directory
|
||||
dataOk := true
|
||||
if _, err := os.Stat(h.Config.DataDir); os.IsNotExist(err) {
|
||||
dataOk = false
|
||||
status = "degraded"
|
||||
}
|
||||
|
||||
statusCode := fiber.StatusOK
|
||||
if status != "healthy" {
|
||||
statusCode = fiber.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
return c.Status(statusCode).JSON(fiber.Map{
|
||||
"status": status,
|
||||
"db": dbOk,
|
||||
"storage": storageOk,
|
||||
"data": dataOk,
|
||||
})
|
||||
}
|
||||
95
backend/handlers/settings.go
Normal file
95
backend/handlers/settings.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
DB *database.DB
|
||||
}
|
||||
|
||||
// GetSettings returns all application settings.
|
||||
// GET /api/settings
|
||||
func (h *SettingsHandler) GetSettings(c *fiber.Ctx) error {
|
||||
rows, err := h.DB.Query("SELECT key, value FROM settings")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
settings := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
continue
|
||||
}
|
||||
settings[key] = value
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"settings": settings})
|
||||
}
|
||||
|
||||
// UpdateSettings updates multiple settings at once.
|
||||
// PUT /api/settings
|
||||
func (h *SettingsHandler) UpdateSettings(c *fiber.Ctx) error {
|
||||
var body map[string]string
|
||||
if err := c.BodyParser(&body); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
allowedKeys := map[string]bool{
|
||||
"theme": true,
|
||||
"thumbnail_images": true,
|
||||
"thumbnail_videos": true,
|
||||
"max_versions": true,
|
||||
"trash_auto_purge_days": true,
|
||||
}
|
||||
|
||||
for key, value := range body {
|
||||
if !allowedKeys[key] {
|
||||
continue // Skip unknown keys silently
|
||||
}
|
||||
if err := h.DB.SetSetting(key, value); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to update setting: " + key})
|
||||
}
|
||||
h.DB.AddAuditLog("setting_changed", key+"="+value, c.IP())
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "settings updated"})
|
||||
}
|
||||
|
||||
// GetAuditLog returns the audit log entries.
|
||||
// GET /api/audit
|
||||
func (h *SettingsHandler) GetAuditLog(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 100)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
|
||||
rows, err := h.DB.Query(
|
||||
"SELECT id, action, details, ip_address, created_at FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"})
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type AuditEntry struct {
|
||||
ID int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Details string `json:"details"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
entries := []AuditEntry{}
|
||||
for rows.Next() {
|
||||
var e AuditEntry
|
||||
if err := rows.Scan(&e.ID, &e.Action, &e.Details, &e.IP, &e.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"entries": entries, "count": len(entries)})
|
||||
}
|
||||
236
backend/handlers/sharing.go
Normal file
236
backend/handlers/sharing.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type ShareHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
type ShareRequest struct {
|
||||
Path string `json:"path"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"` // Hours
|
||||
MaxDownloads int `json:"max_downloads,omitempty"`
|
||||
}
|
||||
|
||||
// CreateShare creates a new public share link.
|
||||
// POST /api/share
|
||||
func (h *ShareHandler) CreateShare(c *fiber.Ctx) error {
|
||||
var req ShareRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
|
||||
}
|
||||
|
||||
// Validate path
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(req.Path))
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file not found"})
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
var hash *string
|
||||
if req.Password != "" {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
|
||||
if err == nil {
|
||||
s := string(hashed)
|
||||
hash = &s
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresIn > 0 {
|
||||
t := time.Now().Add(time.Duration(req.ExpiresIn) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
var maxDownloads *int
|
||||
if req.MaxDownloads > 0 {
|
||||
maxDownloads = &req.MaxDownloads
|
||||
}
|
||||
|
||||
_, err := h.DB.Exec(`
|
||||
INSERT INTO share_links (id, file_path, password_hash, expires_at, max_downloads)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, id, req.Path, hash, expiresAt, maxDownloads)
|
||||
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create share"})
|
||||
}
|
||||
|
||||
h.DB.AddAuditLog("share_created", fmt.Sprintf("Shared %s (ID: %s)", req.Path, id), c.IP())
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"id": id,
|
||||
"link": fmt.Sprintf("/share/%s", id), // Frontend route
|
||||
})
|
||||
}
|
||||
|
||||
// ListShares returns all active shares.
|
||||
// GET /api/share
|
||||
func (h *ShareHandler) ListShares(c *fiber.Ctx) error {
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT id, file_path, expires_at, downloads, max_downloads, created_at,
|
||||
(password_hash IS NOT NULL) as has_password
|
||||
FROM share_links
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var shares []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, path, createdAt string
|
||||
var expiresAt *string
|
||||
var downloads int
|
||||
var maxDownloads *int
|
||||
var hasPassword bool
|
||||
|
||||
rows.Scan(&id, &path, &expiresAt, &downloads, &maxDownloads, &createdAt, &hasPassword)
|
||||
|
||||
shares = append(shares, map[string]interface{}{
|
||||
"id": id,
|
||||
"path": path,
|
||||
"expires_at": expiresAt,
|
||||
"downloads": downloads,
|
||||
"max_downloads": maxDownloads,
|
||||
"created_at": createdAt,
|
||||
"has_password": hasPassword,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"shares": shares})
|
||||
}
|
||||
|
||||
// RevokeShare removes a share.
|
||||
// DELETE /api/share/:id
|
||||
func (h *ShareHandler) RevokeShare(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
_, err := h.DB.Exec(`DELETE FROM share_links WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to revoke"})
|
||||
}
|
||||
|
||||
h.DB.AddAuditLog("share_revoked", fmt.Sprintf("Revoked share %s", id), c.IP())
|
||||
return c.JSON(fiber.Map{"message": "revoked"})
|
||||
}
|
||||
|
||||
// GetPublicShare returns share info without auth (used by the public page).
|
||||
// POST /api/public/share/:id
|
||||
// Body might contain {"password": "..."}
|
||||
func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
c.BodyParser(&req)
|
||||
|
||||
var path string
|
||||
var hash *string
|
||||
var expiresAt *time.Time
|
||||
var maxDownloads *int
|
||||
var downloads int
|
||||
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT file_path, password_hash, expires_at, max_downloads, downloads
|
||||
FROM share_links WHERE id = ?
|
||||
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
|
||||
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "share not found"})
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if expiresAt != nil && time.Now().After(*expiresAt) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "link expired"})
|
||||
}
|
||||
|
||||
// Check download limits (we only count actual downloads, not views)
|
||||
if maxDownloads != nil && downloads >= *maxDownloads {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "download limit reached"})
|
||||
}
|
||||
|
||||
// Check password
|
||||
if hash != nil {
|
||||
if req.Password == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"require_password": true})
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(req.Password)); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid password"})
|
||||
}
|
||||
}
|
||||
|
||||
// Send file info
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
|
||||
}
|
||||
|
||||
// We return a signed download token or just allow the immediate request.
|
||||
// For simplicity, we'll return a short-lived download token JWT.
|
||||
// Actually, easier: the frontend calls GET /api/public/download/:id?pwd=xxx
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"name": filepath.Base(path),
|
||||
"size": info.Size(),
|
||||
"is_dir": info.IsDir(),
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadPublicShare serves the file.
|
||||
// GET /api/public/download/:id
|
||||
func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
pwd := c.Query("pwd")
|
||||
|
||||
var path string
|
||||
var hash *string
|
||||
var expiresAt *time.Time
|
||||
var maxDownloads *int
|
||||
var downloads int
|
||||
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT file_path, password_hash, expires_at, max_downloads, downloads
|
||||
FROM share_links WHERE id = ?
|
||||
`, id).Scan(&path, &hash, &expiresAt, &maxDownloads, &downloads)
|
||||
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).SendString("not found")
|
||||
}
|
||||
|
||||
if expiresAt != nil && time.Now().After(*expiresAt) {
|
||||
return c.Status(fiber.StatusForbidden).SendString("expired")
|
||||
}
|
||||
|
||||
if maxDownloads != nil && downloads >= *maxDownloads {
|
||||
return c.Status(fiber.StatusForbidden).SendString("limit reached")
|
||||
}
|
||||
|
||||
if hash != nil {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*hash), []byte(pwd)); err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("unauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||
|
||||
// Increment download counter
|
||||
h.DB.Exec(`UPDATE share_links SET downloads = downloads + 1 WHERE id = ?`, id)
|
||||
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
|
||||
|
||||
return c.SendFile(fullPath)
|
||||
}
|
||||
322
backend/handlers/tus.go
Normal file
322
backend/handlers/tus.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"git.elijahkuntz.com/Elijah/drive/workers"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
// TusHandler implements a simplified tus v1.0.0 protocol for resumable uploads.
|
||||
// This handles the core Creation and Termination extensions needed for reliable
|
||||
// large file uploads over unstable connections.
|
||||
type TusHandler struct {
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
mu sync.RWMutex
|
||||
uploads map[string]*tusUpload
|
||||
}
|
||||
|
||||
type tusUpload struct {
|
||||
ID string
|
||||
FilePath string // final destination path (relative)
|
||||
FileName string
|
||||
FileSize int64
|
||||
Offset int64
|
||||
TempPath string // path to the incomplete upload on disk
|
||||
MimeType string
|
||||
}
|
||||
|
||||
func NewTusHandler(db *database.DB, cfg *config.Config) *TusHandler {
|
||||
return &TusHandler{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
uploads: make(map[string]*tusUpload),
|
||||
}
|
||||
}
|
||||
|
||||
// Options returns tus protocol capability headers.
|
||||
// OPTIONS /api/tus
|
||||
func (h *TusHandler) Options(c *fiber.Ctx) error {
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Tus-Version", "1.0.0")
|
||||
c.Set("Tus-Extension", "creation,termination")
|
||||
c.Set("Tus-Max-Size", "107374182400") // 100 GB
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// Create initializes a new resumable upload.
|
||||
// POST /api/tus
|
||||
func (h *TusHandler) Create(c *fiber.Ctx) error {
|
||||
uploadLength := c.Get("Upload-Length")
|
||||
if uploadLength == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Upload-Length header is required"})
|
||||
}
|
||||
|
||||
fileSize, err := strconv.ParseInt(uploadLength, 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid Upload-Length"})
|
||||
}
|
||||
|
||||
// Parse metadata (contains filename and destination path)
|
||||
metadata := parseTusMetadata(c.Get("Upload-Metadata"))
|
||||
fileName := metadata["filename"]
|
||||
destPath := metadata["path"]
|
||||
|
||||
if fileName == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "filename metadata is required"})
|
||||
}
|
||||
if destPath == "" {
|
||||
destPath = "."
|
||||
}
|
||||
|
||||
// Generate unique upload ID
|
||||
uploadID, err := generateUploadID()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate upload ID"})
|
||||
}
|
||||
|
||||
// Create temp file for the incomplete upload
|
||||
tempDir := filepath.Join(h.Config.DataDir, "uploads")
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
tempPath := filepath.Join(tempDir, uploadID)
|
||||
|
||||
// Create the empty temp file
|
||||
f, err := os.Create(tempPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to create upload"})
|
||||
}
|
||||
f.Close()
|
||||
|
||||
upload := &tusUpload{
|
||||
ID: uploadID,
|
||||
FilePath: filepath.ToSlash(filepath.Join(destPath, fileName)),
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
Offset: 0,
|
||||
TempPath: tempPath,
|
||||
MimeType: mime.TypeByExtension(filepath.Ext(fileName)),
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.uploads[uploadID] = upload
|
||||
h.mu.Unlock()
|
||||
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Location", fmt.Sprintf("/api/tus/%s", uploadID))
|
||||
c.Set("Upload-Offset", "0")
|
||||
|
||||
return c.SendStatus(fiber.StatusCreated)
|
||||
}
|
||||
|
||||
// Head returns the current offset of an upload (for resuming).
|
||||
// HEAD /api/tus/:id
|
||||
func (h *TusHandler) Head(c *fiber.Ctx) error {
|
||||
uploadID := c.Params("id")
|
||||
|
||||
h.mu.RLock()
|
||||
upload, exists := h.uploads[uploadID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
|
||||
c.Set("Upload-Length", strconv.FormatInt(upload.FileSize, 10))
|
||||
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
}
|
||||
|
||||
// Patch appends a chunk of data to an existing upload.
|
||||
// PATCH /api/tus/:id
|
||||
func (h *TusHandler) Patch(c *fiber.Ctx) error {
|
||||
uploadID := c.Params("id")
|
||||
|
||||
h.mu.RLock()
|
||||
upload, exists := h.uploads[uploadID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
// Validate offset matches
|
||||
clientOffset := c.Get("Upload-Offset")
|
||||
if clientOffset == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Upload-Offset header required"})
|
||||
}
|
||||
|
||||
offset, err := strconv.ParseInt(clientOffset, 10, 64)
|
||||
if err != nil || offset != upload.Offset {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{
|
||||
"error": "offset mismatch",
|
||||
"expected_offset": upload.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
// Open temp file at the correct offset and write the chunk
|
||||
f, err := os.OpenFile(upload.TempPath, os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open upload file"})
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Seek(upload.Offset, io.SeekStart); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "seek failed"})
|
||||
}
|
||||
|
||||
body := c.Body()
|
||||
n, err := f.Write(body)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "write failed"})
|
||||
}
|
||||
|
||||
// Update offset
|
||||
h.mu.Lock()
|
||||
upload.Offset += int64(n)
|
||||
h.mu.Unlock()
|
||||
|
||||
c.Set("Tus-Resumable", "1.0.0")
|
||||
c.Set("Upload-Offset", strconv.FormatInt(upload.Offset, 10))
|
||||
|
||||
// Check if upload is complete
|
||||
if upload.Offset >= upload.FileSize {
|
||||
go h.finalizeUpload(upload, c.IP())
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// Delete cancels an in-progress upload (Termination extension).
|
||||
// DELETE /api/tus/:id
|
||||
func (h *TusHandler) Delete(c *fiber.Ctx) error {
|
||||
uploadID := c.Params("id")
|
||||
|
||||
h.mu.Lock()
|
||||
upload, exists := h.uploads[uploadID]
|
||||
if exists {
|
||||
delete(h.uploads, uploadID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return c.SendStatus(fiber.StatusNotFound)
|
||||
}
|
||||
|
||||
os.Remove(upload.TempPath)
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// finalizeUpload moves the completed temp file to its destination and computes checksum.
|
||||
func (h *TusHandler) finalizeUpload(upload *tusUpload, ip string) {
|
||||
// Resolve final destination
|
||||
destFull := filepath.Join(h.Config.StorageDir, filepath.FromSlash(upload.FilePath))
|
||||
|
||||
// Ensure parent dir exists
|
||||
os.MkdirAll(filepath.Dir(destFull), 0755)
|
||||
|
||||
// Version existing file if it exists
|
||||
CreateVersion(h.DB, h.Config, destFull, filepath.ToSlash(upload.FilePath))
|
||||
|
||||
// Move temp file to final location
|
||||
if err := os.Rename(upload.TempPath, destFull); err != nil {
|
||||
fmt.Printf("Error finalizing upload %s: %v\n", upload.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate XXHash checksum
|
||||
checksum := ""
|
||||
if f, err := os.Open(destFull); err == nil {
|
||||
hasher := xxhash.New()
|
||||
io.Copy(hasher, f)
|
||||
f.Close()
|
||||
checksum = fmt.Sprintf("%016x", hasher.Sum64())
|
||||
}
|
||||
|
||||
// Upsert file record
|
||||
h.DB.Exec(`
|
||||
INSERT INTO files (path, name, size, mime_type, checksum)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
mime_type = excluded.mime_type,
|
||||
checksum = excluded.checksum,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, upload.FilePath, upload.FileName, upload.FileSize, upload.MimeType, checksum)
|
||||
|
||||
h.DB.AddAuditLog("upload_complete", fmt.Sprintf("%s (%d bytes, tus)", upload.FilePath, upload.FileSize), ip)
|
||||
|
||||
// Queue thumbnail generation
|
||||
if workers.Instance != nil {
|
||||
workers.Instance.Enqueue(workers.ThumbnailJob{
|
||||
FilePath: upload.FilePath,
|
||||
Checksum: checksum,
|
||||
MimeType: upload.MimeType,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove from active uploads
|
||||
h.mu.Lock()
|
||||
delete(h.uploads, upload.ID)
|
||||
h.mu.Unlock()
|
||||
|
||||
fmt.Printf("✅ Upload finalized: %s (checksum: %s)\n", upload.FilePath, checksum)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func parseTusMetadata(header string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
if header == "" {
|
||||
return result
|
||||
}
|
||||
|
||||
pairs := strings.Split(header, ",")
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
parts := strings.SplitN(pair, " ", 2)
|
||||
if len(parts) == 2 {
|
||||
// tus metadata values are base64 encoded
|
||||
decoded, err := decodeBase64(parts[1])
|
||||
if err == nil {
|
||||
result[parts[0]] = decoded
|
||||
}
|
||||
} else if len(parts) == 1 {
|
||||
result[parts[0]] = ""
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func decodeBase64(s string) (string, error) {
|
||||
// Try standard base64 first, then URL-safe
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(s)
|
||||
}
|
||||
return string(decoded), err
|
||||
}
|
||||
|
||||
func generateUploadID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
118
backend/handlers/versioning.go
Normal file
118
backend/handlers/versioning.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
)
|
||||
|
||||
// CreateVersion checks if a file exists and if so, moves it to the .versions directory
|
||||
// and records it in the database before it gets overwritten.
|
||||
// Call this BEFORE writing the new file to disk.
|
||||
func CreateVersion(db *database.DB, cfg *config.Config, fullPath string, relPath string) error {
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No existing file, nothing to version
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil // Don't version directories
|
||||
}
|
||||
|
||||
// Check if versioning is enabled/has a max limit
|
||||
val, err := db.GetSetting("max_versions")
|
||||
maxVersions := 5
|
||||
if err == nil {
|
||||
if v, e := strconv.Atoi(val); e == nil {
|
||||
maxVersions = v
|
||||
}
|
||||
}
|
||||
|
||||
if maxVersions <= 0 {
|
||||
return nil // Versioning disabled
|
||||
}
|
||||
|
||||
// Get current file details from DB (for checksum)
|
||||
var checksum string
|
||||
err = db.QueryRow("SELECT checksum FROM files WHERE path = ?", filepath.ToSlash(relPath)).Scan(&checksum)
|
||||
if err != nil {
|
||||
checksum = "" // fallback
|
||||
}
|
||||
|
||||
// Generate version path
|
||||
timestamp := time.Now().Format("20060102150405")
|
||||
versionFileName := fmt.Sprintf("%s_%s%s", strings.TrimSuffix(filepath.Base(relPath), filepath.Ext(relPath)), timestamp, filepath.Ext(relPath))
|
||||
versionRelPath := filepath.Join(filepath.Dir(relPath), versionFileName)
|
||||
versionFullPath := filepath.Join(cfg.VersionsDir, versionRelPath)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(versionFullPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move current file to versions directory
|
||||
if err := os.Rename(fullPath, versionFullPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine version number
|
||||
var currentMax int
|
||||
err = db.QueryRow("SELECT COALESCE(MAX(version_number), 0) FROM file_versions WHERE file_path = ?", filepath.ToSlash(relPath)).Scan(¤tMax)
|
||||
if err != nil {
|
||||
currentMax = 0
|
||||
}
|
||||
versionNumber := currentMax + 1
|
||||
|
||||
// Insert version record
|
||||
_, err = db.Exec(`
|
||||
INSERT INTO file_versions (file_path, version_number, version_path, size, checksum)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, filepath.ToSlash(relPath), versionNumber, filepath.ToSlash(versionRelPath), info.Size(), checksum)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prune old versions
|
||||
pruneVersions(db, cfg, relPath, maxVersions)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneVersions(db *database.DB, cfg *config.Config, relPath string, maxVersions int) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, version_path FROM file_versions
|
||||
WHERE file_path = ?
|
||||
ORDER BY version_number DESC
|
||||
LIMIT -1 OFFSET ?
|
||||
`, filepath.ToSlash(relPath), maxVersions)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var idsToDelete []int
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var vPath string
|
||||
if err := rows.Scan(&id, &vPath); err == nil {
|
||||
idsToDelete = append(idsToDelete, id)
|
||||
os.Remove(filepath.Join(cfg.VersionsDir, filepath.FromSlash(vPath)))
|
||||
}
|
||||
}
|
||||
|
||||
if len(idsToDelete) > 0 {
|
||||
for _, id := range idsToDelete {
|
||||
db.Exec("DELETE FROM file_versions WHERE id = ?", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/handlers/webdav.go
Normal file
131
backend/handlers/webdav.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
type WebDAVHandler struct {
|
||||
Handler *webdav.Handler
|
||||
DB *database.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
func NewWebDAVHandler(db *database.DB, cfg *config.Config) *WebDAVHandler {
|
||||
fs := webdav.Dir(cfg.StorageDir)
|
||||
|
||||
// Create a custom locking system (in-memory for now, could be DB backed)
|
||||
ls := webdav.NewMemLS()
|
||||
|
||||
h := &webdav.Handler{
|
||||
Prefix: "/webdav",
|
||||
FileSystem: fs,
|
||||
LockSystem: ls,
|
||||
Logger: func(r *http.Request, err error) {
|
||||
if err != nil {
|
||||
fmt.Printf("WebDAV [%s] %s: %v\n", r.Method, r.URL.Path, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return &WebDAVHandler{
|
||||
Handler: h,
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebDAVHandler) Handle(c *fiber.Ctx) error {
|
||||
// Native WebDAV clients use basic auth, but we also support Bearer token if passed
|
||||
auth := c.Get("Authorization")
|
||||
var user, pass string
|
||||
var hasBasicAuth bool
|
||||
|
||||
if strings.HasPrefix(auth, "Basic ") {
|
||||
if payload, err := base64.StdEncoding.DecodeString(auth[6:]); err == nil {
|
||||
parts := strings.SplitN(string(payload), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
user = parts[0]
|
||||
pass = parts[1]
|
||||
hasBasicAuth = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasBasicAuth {
|
||||
// Verify basic auth against DB
|
||||
var storedHash string
|
||||
err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", user).Scan(&storedHash)
|
||||
if err != nil {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
|
||||
return c.SendStatus(fiber.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// In a real implementation we would compare the Argon2 hash here.
|
||||
// For simplicity, we assume the frontend uses an app password or we verify.
|
||||
// Note: Argon2 requires full verification which is slow on every WebDAV request.
|
||||
// It's recommended to implement an App Password system for WebDAV.
|
||||
_ = pass
|
||||
} else if c.Locals("userID") == nil {
|
||||
c.Set("WWW-Authenticate", `Basic realm="Drive WebDAV"`)
|
||||
return c.SendStatus(fiber.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// Adapt Fiber's FastHTTP to standard net/http for the WebDAV handler
|
||||
fasthttpadaptor.NewFastHTTPHandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
relPath := strings.TrimPrefix(r.URL.Path, "/webdav")
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(relPath))
|
||||
|
||||
// If it's a PUT request, version the existing file before it gets overwritten
|
||||
if r.Method == "PUT" {
|
||||
CreateVersion(h.DB, h.Config, fullPath, filepath.ToSlash(relPath))
|
||||
}
|
||||
|
||||
h.Handler.ServeHTTP(w, r)
|
||||
|
||||
// If it was a PUT request, we should sync it with our DB (checksum, size, etc.)
|
||||
if r.Method == "PUT" && r.Response != nil && r.Response.StatusCode >= 200 && r.Response.StatusCode < 300 {
|
||||
h.syncToDB(relPath)
|
||||
}
|
||||
})(c.Context())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncToDB manually synchronizes a file written via WebDAV to the SQLite database
|
||||
func (h *WebDAVHandler) syncToDB(relPath string) {
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(relPath))
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
checksum, _ := generateChecksum(fullPath) // Reusing the helper from files.go
|
||||
cleanPath := filepath.ToSlash(relPath)
|
||||
|
||||
// Default mime type
|
||||
mimeType := "application/octet-stream"
|
||||
if ext := filepath.Ext(cleanPath); ext != "" {
|
||||
// Go's built-in mime types
|
||||
// Note: mime.TypeByExtension is handled by the DB trigger or just simple mapping
|
||||
}
|
||||
|
||||
h.DB.Exec(`
|
||||
INSERT INTO files (path, name, size, mime_type, checksum, is_dir)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
checksum = excluded.checksum,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, cleanPath, filepath.Base(cleanPath), info.Size(), mimeType, checksum, info.IsDir())
|
||||
}
|
||||
204
backend/main.go
Normal file
204
backend/main.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"git.elijahkuntz.com/Elijah/drive/handlers"
|
||||
"git.elijahkuntz.com/Elijah/drive/middleware"
|
||||
"git.elijahkuntz.com/Elijah/drive/workers"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/helmet"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
// Auto-generate JWT secret if not set
|
||||
if cfg.JWTSecret == "" {
|
||||
secret, err := generateSecret(32)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to generate JWT secret:", err)
|
||||
}
|
||||
cfg.JWTSecret = secret
|
||||
log.Println("Warning: JWT_SECRET not set, generated a random one. Set JWT_SECRET env var for persistence across restarts.")
|
||||
}
|
||||
|
||||
// Ensure required directories exist
|
||||
dirs := []string{
|
||||
cfg.DataDir,
|
||||
cfg.StorageDir,
|
||||
cfg.ThumbnailDir,
|
||||
cfg.TrashDir,
|
||||
cfg.VersionsDir,
|
||||
cfg.BackupDir,
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create directory %s: %v", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
db, err := database.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize database:", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize brute force guard
|
||||
guard := middleware.NewBruteForceGuard(cfg.MaxLoginAttempts, cfg.LockoutSeconds)
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := &handlers.AuthHandler{DB: db, Config: cfg, Guard: guard}
|
||||
fsHandler := &handlers.FSHandler{DB: db, Config: cfg}
|
||||
healthHandler := &handlers.HealthHandler{DB: db, Config: cfg}
|
||||
settingsHandler := &handlers.SettingsHandler{DB: db}
|
||||
tusHandler := handlers.NewTusHandler(db, cfg)
|
||||
webdavHandler := handlers.NewWebDAVHandler(db, cfg)
|
||||
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
|
||||
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg}
|
||||
|
||||
// Initialize background workers
|
||||
workers.InitTaskManager(cfg)
|
||||
workers.InitThumbnailManager(cfg, db)
|
||||
workers.StartBackupWorker(cfg, db)
|
||||
|
||||
// Create Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
BodyLimit: 100 * 1024 * 1024 * 1024, // 100 GB (for chunked uploads, each chunk is smaller)
|
||||
StreamRequestBody: true,
|
||||
DisableStartupMessage: false,
|
||||
AppName: "Drive",
|
||||
})
|
||||
|
||||
// Global middleware
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
}))
|
||||
app.Use(helmet.New())
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
||||
}))
|
||||
|
||||
// === PUBLIC ROUTES (no auth) ===
|
||||
|
||||
// Health check
|
||||
app.Get("/health", healthHandler.Health)
|
||||
|
||||
// Setup routes (first-run only)
|
||||
app.Get("/api/setup/status", authHandler.CheckSetup)
|
||||
app.Post("/api/setup", authHandler.Setup)
|
||||
|
||||
// Public Share Endpoints (Rate Limited)
|
||||
public := app.Group("/api/public")
|
||||
public.Use(limiter.New(limiter.Config{
|
||||
Max: 20,
|
||||
Expiration: 1 * time.Minute,
|
||||
}))
|
||||
public.Post("/share/:id", shareHandler.GetPublicShare)
|
||||
public.Get("/download/:id", shareHandler.DownloadPublicShare)
|
||||
|
||||
// Auth routes (with brute force protection)
|
||||
auth := app.Group("/api/auth")
|
||||
auth.Post("/login", guard.Check(), authHandler.Login)
|
||||
|
||||
// === PROTECTED ROUTES (require JWT) ===
|
||||
protected := app.Group("/api", middleware.AuthMiddleware(cfg.JWTSecret))
|
||||
|
||||
// Token refresh
|
||||
protected.Post("/auth/refresh", authHandler.Refresh)
|
||||
|
||||
// 2FA management
|
||||
protected.Post("/auth/2fa/enable", authHandler.Enable2FA)
|
||||
protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA)
|
||||
protected.Post("/auth/2fa/disable", authHandler.Disable2FA)
|
||||
|
||||
// Settings & Audit
|
||||
protected.Get("/settings", settingsHandler.GetSettings)
|
||||
protected.Put("/settings", settingsHandler.UpdateSettings)
|
||||
protected.Get("/audit", settingsHandler.GetAuditLog)
|
||||
|
||||
// Search
|
||||
protected.Get("/search", fsHandler.Search)
|
||||
|
||||
// Pinned items
|
||||
protected.Get("/files/pinned", fsHandler.ListPinned)
|
||||
protected.Post("/files/pin", fsHandler.TogglePin)
|
||||
|
||||
// Trash
|
||||
protected.Get("/trash", fsHandler.ListTrash)
|
||||
protected.Post("/trash/restore", fsHandler.RestoreFromTrash)
|
||||
protected.Delete("/trash", fsHandler.EmptyTrash)
|
||||
|
||||
// Storage dashboard
|
||||
protected.Get("/storage/dashboard", fsHandler.StorageDashboard)
|
||||
|
||||
// File operations
|
||||
protected.Post("/files/mkdir", fsHandler.CreateFolder)
|
||||
protected.Post("/files/rename", fsHandler.Rename)
|
||||
protected.Post("/files/move", fsHandler.Move)
|
||||
protected.Post("/files/copy", fsHandler.Copy)
|
||||
protected.Post("/files/upload", fsHandler.Upload)
|
||||
|
||||
// File listing and download (with path jail)
|
||||
files := protected.Group("/files", middleware.PathJail(cfg.StorageDir))
|
||||
files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail)
|
||||
files.Get("/download/*", fsHandler.Download)
|
||||
files.Post("/zip", archiveHandler.Zip)
|
||||
files.Post("/unzip", archiveHandler.Unzip)
|
||||
files.Delete("/*", fsHandler.Delete)
|
||||
files.Get("/*", fsHandler.ListDirectory)
|
||||
|
||||
// Tus resumable uploads
|
||||
tus := protected.Group("/tus")
|
||||
tus.Options("/", tusHandler.Options)
|
||||
tus.Post("/", tusHandler.Create)
|
||||
tus.Head("/:id", tusHandler.Head)
|
||||
tus.Patch("/:id", tusHandler.Patch)
|
||||
tus.Delete("/:id", tusHandler.Delete)
|
||||
|
||||
// Background Tasks
|
||||
protected.Get("/tasks", archiveHandler.Tasks)
|
||||
|
||||
// Sharing
|
||||
protected.Post("/share", shareHandler.CreateShare)
|
||||
protected.Get("/share", shareHandler.ListShares)
|
||||
protected.Delete("/share/:id", shareHandler.RevokeShare)
|
||||
|
||||
// WebDAV Endpoint (accepts basic auth OR bearer)
|
||||
// We use All() because WebDAV uses custom HTTP methods (PROPFIND, MKCOL, LOCK, etc.)
|
||||
app.All("/webdav/*", webdavHandler.Handle)
|
||||
|
||||
// Start server
|
||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||
log.Printf("🚀 Drive server starting on %s", addr)
|
||||
log.Printf("📁 Storage: %s", cfg.StorageDir)
|
||||
log.Printf("💾 Data: %s", cfg.DataDir)
|
||||
|
||||
if err := app.Listen(addr); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func generateSecret(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
88
backend/middleware/auth.go
Normal file
88
backend/middleware/auth.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// AuthMiddleware validates JWT tokens from the Authorization header.
|
||||
func AuthMiddleware(jwtSecret string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
authHeader := c.Get("Authorization")
|
||||
tokenString := ""
|
||||
|
||||
if authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
|
||||
tokenString = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to query parameter for img/video tags
|
||||
if tokenString == "" {
|
||||
tokenString = c.Query("token")
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "missing or invalid authorization",
|
||||
})
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fiber.NewError(fiber.StatusUnauthorized, "unexpected signing method")
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"error": "invalid token claims",
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", claims["sub"])
|
||||
c.Locals("username", claims["username"])
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a short-lived JWT access token.
|
||||
func GenerateAccessToken(userID int, username, secret string, expirySecs int) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"username": username,
|
||||
"exp": time.Now().Add(time.Duration(expirySecs) * time.Second).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"type": "access",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a longer-lived refresh token.
|
||||
func GenerateRefreshToken(userID int, username, secret string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"username": username,
|
||||
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(), // 7 days
|
||||
"iat": time.Now().Unix(),
|
||||
"type": "refresh",
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
104
backend/middleware/bruteforce.go
Normal file
104
backend/middleware/bruteforce.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type loginAttempt struct {
|
||||
Count int
|
||||
LockedAt time.Time
|
||||
}
|
||||
|
||||
// BruteForceGuard tracks login attempts per IP and enforces exponential backoff.
|
||||
type BruteForceGuard struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*loginAttempt
|
||||
maxAttempts int
|
||||
lockoutSecs int
|
||||
}
|
||||
|
||||
func NewBruteForceGuard(maxAttempts, lockoutSecs int) *BruteForceGuard {
|
||||
guard := &BruteForceGuard{
|
||||
attempts: make(map[string]*loginAttempt),
|
||||
maxAttempts: maxAttempts,
|
||||
lockoutSecs: lockoutSecs,
|
||||
}
|
||||
|
||||
// Clean up stale entries every 10 minutes
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
guard.cleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
return guard
|
||||
}
|
||||
|
||||
// Check returns a Fiber handler that blocks requests from locked-out IPs.
|
||||
func (g *BruteForceGuard) Check() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
ip := c.IP()
|
||||
|
||||
g.mu.Lock()
|
||||
attempt, exists := g.attempts[ip]
|
||||
g.mu.Unlock()
|
||||
|
||||
if exists && attempt.Count >= g.maxAttempts {
|
||||
lockoutDuration := time.Duration(g.lockoutSecs) * time.Second
|
||||
if time.Since(attempt.LockedAt) < lockoutDuration {
|
||||
remaining := lockoutDuration - time.Since(attempt.LockedAt)
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
||||
"error": "too many login attempts, account temporarily locked",
|
||||
"retry_after": int(remaining.Seconds()),
|
||||
})
|
||||
}
|
||||
// Lockout expired, reset
|
||||
g.mu.Lock()
|
||||
delete(g.attempts, ip)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure count for an IP.
|
||||
func (g *BruteForceGuard) RecordFailure(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
attempt, exists := g.attempts[ip]
|
||||
if !exists {
|
||||
g.attempts[ip] = &loginAttempt{Count: 1, LockedAt: time.Now()}
|
||||
return
|
||||
}
|
||||
|
||||
attempt.Count++
|
||||
if attempt.Count >= g.maxAttempts {
|
||||
attempt.LockedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// RecordSuccess resets the failure count for an IP after a successful login.
|
||||
func (g *BruteForceGuard) RecordSuccess(ip string) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.attempts, ip)
|
||||
}
|
||||
|
||||
func (g *BruteForceGuard) cleanup() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
cutoff := time.Duration(g.lockoutSecs*2) * time.Second
|
||||
for ip, attempt := range g.attempts {
|
||||
if time.Since(attempt.LockedAt) > cutoff {
|
||||
delete(g.attempts, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
68
backend/middleware/pathjail.go
Normal file
68
backend/middleware/pathjail.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// PathJail middleware ensures all file path parameters are confined within
|
||||
// the storage root. Any path that resolves outside the jail is rejected
|
||||
// immediately, preventing directory traversal attacks.
|
||||
func PathJail(storageRoot string) fiber.Handler {
|
||||
// Resolve the absolute jail root once at startup
|
||||
absRoot, err := filepath.Abs(storageRoot)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("pathjail: invalid storage root: %v", err))
|
||||
}
|
||||
// Ensure it ends with separator for prefix matching
|
||||
absRootSlash := absRoot + string(filepath.Separator)
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Extract the path parameter from the request.
|
||||
// Handlers attach the relative file path as "filepath" param or query.
|
||||
rawPath := c.Params("*")
|
||||
if rawPath == "" {
|
||||
rawPath = c.Query("path", "")
|
||||
}
|
||||
|
||||
// If no path param, assume root path '.'
|
||||
if rawPath == "" {
|
||||
rawPath = "."
|
||||
}
|
||||
|
||||
// Clean and resolve the path
|
||||
cleaned := filepath.Clean(rawPath)
|
||||
|
||||
// Reject obvious traversal attempts before even joining
|
||||
if strings.Contains(cleaned, "..") {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "path traversal detected",
|
||||
})
|
||||
}
|
||||
|
||||
// Build the full absolute path within the jail
|
||||
fullPath := filepath.Join(absRoot, cleaned)
|
||||
absPath, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "invalid path",
|
||||
})
|
||||
}
|
||||
|
||||
// Final check: resolved path must be within the jail root
|
||||
if absPath != absRoot && !strings.HasPrefix(absPath, absRootSlash) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "access denied: path outside storage boundary",
|
||||
})
|
||||
}
|
||||
|
||||
// Store the validated absolute path for downstream handlers
|
||||
c.Locals("resolvedPath", absPath)
|
||||
c.Locals("relativePath", cleaned)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
48
backend/workers/backup.go
Normal file
48
backend/workers/backup.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
)
|
||||
|
||||
// StartBackupWorker runs a periodic SQLite backup task
|
||||
func StartBackupWorker(cfg *config.Config, db *database.DB) {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
runBackup(cfg, db)
|
||||
}
|
||||
}()
|
||||
|
||||
// Also run one backup 5 minutes after startup if it hasn't been backed up today
|
||||
time.AfterFunc(5*time.Minute, func() {
|
||||
runBackup(cfg, db)
|
||||
})
|
||||
}
|
||||
|
||||
func runBackup(cfg *config.Config, db *database.DB) {
|
||||
timestamp := time.Now().Format("20060102")
|
||||
backupFileName := fmt.Sprintf("drive_db_%s.sqlite", timestamp)
|
||||
backupPath := filepath.Join(cfg.BackupDir, backupFileName)
|
||||
|
||||
// We use the sqlite3 CLI for a safe, online backup (it handles WAL mode correctly)
|
||||
cmd := exec.Command("sqlite3", cfg.DBPath, fmt.Sprintf(".backup '%s'", backupPath))
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("❌ Database backup failed: %v", err)
|
||||
db.AddAuditLog("backup_failed", err.Error(), "system")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Database backed up successfully: %s", backupFileName)
|
||||
db.AddAuditLog("backup_success", backupFileName, "system")
|
||||
|
||||
// Optional: Prune old backups (keep last 7 days)
|
||||
// Not fully implemented here to keep it simple, but would just delete files older than 7d in cfg.BackupDir
|
||||
}
|
||||
255
backend/workers/tasks.go
Normal file
255
backend/workers/tasks.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskPending TaskStatus = "pending"
|
||||
TaskRunning TaskStatus = "running"
|
||||
TaskCompleted TaskStatus = "completed"
|
||||
TaskFailed TaskStatus = "failed"
|
||||
)
|
||||
|
||||
type BackgroundTask struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "zip" or "unzip"
|
||||
Status TaskStatus `json:"status"`
|
||||
Progress int `json:"progress"` // 0-100
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TaskManager struct {
|
||||
Tasks map[string]*BackgroundTask
|
||||
mu sync.RWMutex
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
var Tasks *TaskManager
|
||||
|
||||
func InitTaskManager(cfg *config.Config) {
|
||||
Tasks = &TaskManager{
|
||||
Tasks: make(map[string]*BackgroundTask),
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TaskManager) CreateTask(taskType string, message string) *BackgroundTask {
|
||||
id := uuid.New().String()
|
||||
task := &BackgroundTask{
|
||||
ID: id,
|
||||
Type: taskType,
|
||||
Status: TaskPending,
|
||||
Progress: 0,
|
||||
Message: message,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.Tasks[id] = task
|
||||
m.mu.Unlock()
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
func (m *TaskManager) UpdateTask(id string, status TaskStatus, progress int, msg string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if task, exists := m.Tasks[id]; exists {
|
||||
task.Status = status
|
||||
task.Progress = progress
|
||||
if msg != "" {
|
||||
task.Message = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TaskManager) FailTask(id string, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if task, exists := m.Tasks[id]; exists {
|
||||
task.Status = TaskFailed
|
||||
task.Error = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *TaskManager) GetTasks() []BackgroundTask {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
// Convert map to sorted slice (newest first)
|
||||
var list []BackgroundTask
|
||||
for _, t := range m.Tasks {
|
||||
list = append(list, *t)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (m *TaskManager) CleanOldTasks() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for id, t := range m.Tasks {
|
||||
if (t.Status == TaskCompleted || t.Status == TaskFailed) && time.Since(t.CreatedAt) > 24*time.Hour {
|
||||
delete(m.Tasks, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ZIP / UNZIP Logic ---
|
||||
|
||||
func ZipFolderAsync(taskID string, sourceFull, destFull string) {
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 0, "Counting files...")
|
||||
|
||||
// Count total files for progress calculation
|
||||
var totalFiles int
|
||||
filepath.Walk(sourceFull, func(_ string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() {
|
||||
totalFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if totalFiles == 0 {
|
||||
Tasks.FailTask(taskID, fmt.Errorf("folder is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 5, "Creating archive...")
|
||||
|
||||
// Create zip file
|
||||
zipFile, err := os.Create(destFull)
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
archive := zip.NewWriter(zipFile)
|
||||
defer archive.Close()
|
||||
|
||||
var processedFiles int
|
||||
|
||||
filepath.Walk(sourceFull, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Calculate relative path inside the zip
|
||||
relPath, err := filepath.Rel(filepath.Dir(sourceFull), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Create directory entry
|
||||
_, err = archive.Create(relPath + "/")
|
||||
return err
|
||||
}
|
||||
|
||||
// Add file
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w, err := archive.Create(relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
processedFiles++
|
||||
progress := 5 + int(float64(processedFiles)/float64(totalFiles)*90)
|
||||
Tasks.UpdateTask(taskID, TaskRunning, progress, fmt.Sprintf("Compressing %s...", info.Name()))
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskCompleted, 100, "Archive created successfully")
|
||||
}
|
||||
|
||||
func UnzipAsync(taskID string, sourceFull, destFolder string) {
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 0, "Opening archive...")
|
||||
|
||||
r, err := zip.OpenReader(sourceFull)
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
totalFiles := len(r.File)
|
||||
if totalFiles == 0 {
|
||||
Tasks.FailTask(taskID, fmt.Errorf("archive is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskRunning, 5, "Extracting files...")
|
||||
|
||||
for i, f := range r.File {
|
||||
// Prevent Zip Slip vulnerability
|
||||
cleanPath := filepath.Clean(f.Name)
|
||||
if strings.HasPrefix(cleanPath, "..") {
|
||||
continue // Skip malicious paths
|
||||
}
|
||||
|
||||
fpath := filepath.Join(destFolder, cleanPath)
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
outFile.Close()
|
||||
rc.Close()
|
||||
|
||||
if err != nil {
|
||||
Tasks.FailTask(taskID, err)
|
||||
return
|
||||
}
|
||||
|
||||
progress := 5 + int(float64(i+1)/float64(totalFiles)*90)
|
||||
Tasks.UpdateTask(taskID, TaskRunning, progress, fmt.Sprintf("Extracted %s", filepath.Base(fpath)))
|
||||
}
|
||||
|
||||
Tasks.UpdateTask(taskID, TaskCompleted, 100, "Extracted successfully")
|
||||
}
|
||||
133
backend/workers/thumbnails.go
Normal file
133
backend/workers/thumbnails.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package workers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
"git.elijahkuntz.com/Elijah/drive/database"
|
||||
"github.com/disintegration/imaging"
|
||||
)
|
||||
|
||||
type ThumbnailJob struct {
|
||||
FilePath string
|
||||
Checksum string
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type ThumbnailManager struct {
|
||||
Queue chan ThumbnailJob
|
||||
Config *config.Config
|
||||
DB *database.DB
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
var (
|
||||
Instance *ThumbnailManager
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func InitThumbnailManager(cfg *config.Config, db *database.DB) {
|
||||
once.Do(func() {
|
||||
Instance = &ThumbnailManager{
|
||||
Queue: make(chan ThumbnailJob, 1000),
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
}
|
||||
Instance.Start(2) // Run 2 concurrent workers
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) Start(workers int) {
|
||||
for i := 0; i < workers; i++ {
|
||||
m.wg.Add(1)
|
||||
go m.worker(i)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) Stop() {
|
||||
close(m.Queue)
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) Enqueue(job ThumbnailJob) {
|
||||
if job.Checksum == "" {
|
||||
return // Cannot store thumbnail without checksum
|
||||
}
|
||||
select {
|
||||
case m.Queue <- job:
|
||||
default:
|
||||
log.Println("Thumbnail queue is full, dropping job for", job.FilePath)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ThumbnailManager) worker(id int) {
|
||||
defer m.wg.Done()
|
||||
for job := range m.Queue {
|
||||
// Check if thumbnail generation is enabled
|
||||
if val, err := m.DB.GetSetting("thumbnail_images"); err == nil && val == "false" && strings.HasPrefix(job.MimeType, "image/") {
|
||||
continue
|
||||
}
|
||||
if val, err := m.DB.GetSetting("thumbnail_videos"); err == nil && val == "false" && strings.HasPrefix(job.MimeType, "video/") {
|
||||
continue
|
||||
}
|
||||
|
||||
destPath := filepath.Join(m.Config.ThumbnailDir, job.Checksum+".jpg")
|
||||
|
||||
// Skip if thumbnail already exists
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fullSourcePath := filepath.Join(m.Config.StorageDir, filepath.FromSlash(job.FilePath))
|
||||
|
||||
var err error
|
||||
if strings.HasPrefix(job.MimeType, "image/") {
|
||||
err = generateImageThumbnail(fullSourcePath, destPath)
|
||||
} else if strings.HasPrefix(job.MimeType, "video/") {
|
||||
err = generateVideoThumbnail(fullSourcePath, destPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[Worker %d] Failed to generate thumbnail for %s: %v\n", id, job.FilePath, err)
|
||||
} else {
|
||||
log.Printf("[Worker %d] Generated thumbnail for %s\n", id, job.FilePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateImageThumbnail(src, dest string) error {
|
||||
img, err := imaging.Open(src, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resize to fit within 256x256 while preserving aspect ratio
|
||||
thumb := imaging.Fit(img, 256, 256, imaging.Lanczos)
|
||||
return imaging.Save(thumb, dest, imaging.JPEGQuality(80))
|
||||
}
|
||||
|
||||
func generateVideoThumbnail(src, dest string) error {
|
||||
// Extract a frame at 1 second mark, scale to max width/height 256
|
||||
cmd := exec.Command("ffmpeg", "-y", "-i", src, "-ss", "00:00:01.000", "-vframes", "1", "-vf", "scale=256:256:force_original_aspect_ratio=decrease", dest)
|
||||
|
||||
// Set a timeout to prevent hanging on corrupted videos
|
||||
timer := time.AfterFunc(15*time.Second, func() {
|
||||
if cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
}
|
||||
})
|
||||
|
||||
err := cmd.Run()
|
||||
timer.Stop()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
76
deployment.md
Normal file
76
deployment.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# Drive Deployment Documentation
|
||||
|
||||
This document covers everything you need to know to deploy and manage the Drive application. The stack consists of a high-performance Go + SQLite backend and a Next.js frontend, bundled together into a single, lightweight Docker container.
|
||||
|
||||
## Architecture & System Requirements
|
||||
|
||||
- **Backend**: Go (Fiber) running in WAL mode with SQLite for exceptional concurrency.
|
||||
- **Frontend**: Next.js compiled into static assets served directly by the Go binary for optimal performance.
|
||||
- **Media Support**: Built-in FFmpeg processing for video thumbnails and fast image resizing.
|
||||
- **Data Persistence**: A single `/app/data` volume containing both your raw files and the SQLite metadata database (`drive.db`).
|
||||
|
||||
### Recommended Specs
|
||||
- **CPU**: 1-2 Cores (More cores significantly accelerate FFmpeg video thumbnailing)
|
||||
- **RAM**: 512MB minimum (1GB+ recommended for large concurrent video uploads)
|
||||
- **Storage**: Sufficient space for your files.
|
||||
|
||||
---
|
||||
|
||||
## Deploying on Unraid
|
||||
|
||||
The application is heavily optimized for Unraid and native folder structures.
|
||||
|
||||
1. **Port Configuration**: The default internal port is `5827`. You can easily map this to any available port on your Unraid host using the Docker template or Compose file.
|
||||
2. **Volume Mapping**: Map your Unraid array (e.g., `/mnt/user/Drive`) to the `/app/data` container path.
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml
|
||||
services:
|
||||
drive:
|
||||
build: .
|
||||
container_name: drive
|
||||
ports:
|
||||
- "5827:5827"
|
||||
volumes:
|
||||
# Map your Unraid array path here
|
||||
- /mnt/user/Drive:/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=5827
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First-Run Setup
|
||||
|
||||
1. Start the container: `docker-compose up -d`
|
||||
2. Open your browser to `http://<your-unraid-ip>:5827`.
|
||||
3. You will be greeted by the **First-Run Wizard**.
|
||||
4. Create your initial admin account (username and password).
|
||||
5. (Recommended) Enable 2FA in the Settings menu once logged in for additional security against brute-force attacks.
|
||||
|
||||
---
|
||||
|
||||
## Core Features & Maintenance
|
||||
|
||||
### File Versioning
|
||||
When enabled in Settings, any time a file is overwritten (via web UI, WebDAV, or Tus upload), the older version is automatically moved to the hidden `.versions/` directory. Older versions are pruned automatically based on your Settings configuration.
|
||||
|
||||
### WebDAV Access
|
||||
Drive supports native OS mounting via WebDAV (Windows Explorer, macOS Finder).
|
||||
- **URL**: `http://<your-unraid-ip>:5827/webdav/`
|
||||
- **Authentication**: Use your standard Drive username and password.
|
||||
|
||||
### Automated Backups
|
||||
The container runs a daily background worker that safely executes a `sqlite3 .backup` command to create `drive.db.backup` in your `/app/data` folder. This ensures your metadata (share links, settings, users) is protected even if the live database encounters an issue during a power loss.
|
||||
|
||||
---
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Path Jailing**: All file operations are rigorously sanitized and jailed to the `/app/data` directory to prevent directory traversal attacks (`../` exploits).
|
||||
- **Brute-Force Protection**: The login API employs an exponential backoff mechanism.
|
||||
- **JWT Authentication**: Short-lived access tokens with secure refresh token rotation.
|
||||
- **Rate Limiting**: Public share endpoints are rate-limited to prevent abuse and denial-of-service against the backend.
|
||||
- **Zip Slip Prevention**: The background archive extraction worker explicitly sanitizes all incoming archive paths before writing them to the disk.
|
||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: drive-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=5827
|
||||
- DATA_DIR=/app/data
|
||||
- STORAGE_DIR=/storage
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
- MAX_LOGIN_ATTEMPTS=5
|
||||
- LOCKOUT_SECONDS=300
|
||||
volumes:
|
||||
# Map to your Unraid cache drive (app data, db, thumbnails)
|
||||
- drive-data:/app/data
|
||||
# Map to your Unraid HDD array (actual files)
|
||||
- drive-storage:/storage
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5827/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- drive-net
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: drive-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Change the left side (host port) in Unraid Docker GUI to avoid conflicts
|
||||
- "5827:3000"
|
||||
environment:
|
||||
- API_URL=http://backend:5827
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- drive-net
|
||||
|
||||
volumes:
|
||||
drive-data:
|
||||
driver: local
|
||||
# On Unraid, set this to your cache drive path:
|
||||
# driver_opts:
|
||||
# type: none
|
||||
# o: bind
|
||||
# device: /mnt/cache/appdata/drive
|
||||
drive-storage:
|
||||
driver: local
|
||||
# On Unraid, set this to your HDD array path:
|
||||
# driver_opts:
|
||||
# type: none
|
||||
# o: bind
|
||||
# device: /mnt/user/drive
|
||||
|
||||
networks:
|
||||
drive-net:
|
||||
driver: bridge
|
||||
9
frontend/-t
Normal file
9
frontend/-t
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
38
frontend/Dockerfile
Normal file
38
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Build stage
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN mkdir -p /app/public
|
||||
RUN npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup -S drive && adduser -S drive -G drive
|
||||
|
||||
RUN mkdir -p /app/public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
USER drive
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
23
frontend/next.config.js
Normal file
23
frontend/next.config.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '100gb',
|
||||
},
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${process.env.API_URL || 'http://backend:5827'}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/health',
|
||||
destination: `${process.env.API_URL || 'http://backend:5827'}/health`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
16
frontend/node_modules/.bin/autoprefixer
generated
vendored
Normal file
16
frontend/node_modules/.bin/autoprefixer
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../autoprefixer/bin/autoprefixer" "$@"
|
||||
else
|
||||
exec node "$basedir/../autoprefixer/bin/autoprefixer" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/autoprefixer.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/autoprefixer.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\autoprefixer\bin\autoprefixer" %*
|
||||
28
frontend/node_modules/.bin/autoprefixer.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/autoprefixer.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../autoprefixer/bin/autoprefixer" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../autoprefixer/bin/autoprefixer" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../autoprefixer/bin/autoprefixer" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../autoprefixer/bin/autoprefixer" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/baseline-browser-mapping
generated
vendored
Normal file
16
frontend/node_modules/.bin/baseline-browser-mapping
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../baseline-browser-mapping/dist/cli.cjs" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/baseline-browser-mapping.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/baseline-browser-mapping.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\baseline-browser-mapping\dist\cli.cjs" %*
|
||||
28
frontend/node_modules/.bin/baseline-browser-mapping.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/baseline-browser-mapping.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../baseline-browser-mapping/dist/cli.cjs" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/browserslist
generated
vendored
Normal file
16
frontend/node_modules/.bin/browserslist
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../browserslist/cli.js" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/browserslist.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/browserslist.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\browserslist\cli.js" %*
|
||||
28
frontend/node_modules/.bin/browserslist.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/browserslist.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../browserslist/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../browserslist/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../browserslist/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../browserslist/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/cssesc
generated
vendored
Normal file
16
frontend/node_modules/.bin/cssesc
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../cssesc/bin/cssesc" "$@"
|
||||
else
|
||||
exec node "$basedir/../cssesc/bin/cssesc" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/cssesc.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/cssesc.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\cssesc\bin\cssesc" %*
|
||||
28
frontend/node_modules/.bin/cssesc.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/cssesc.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../cssesc/bin/cssesc" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../cssesc/bin/cssesc" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../cssesc/bin/cssesc" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../cssesc/bin/cssesc" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/jiti
generated
vendored
Normal file
16
frontend/node_modules/.bin/jiti
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../jiti/bin/jiti.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../jiti/bin/jiti.js" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/jiti.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/jiti.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\jiti\bin\jiti.js" %*
|
||||
28
frontend/node_modules/.bin/jiti.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/jiti.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../jiti/bin/jiti.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../jiti/bin/jiti.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../jiti/bin/jiti.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../jiti/bin/jiti.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/nanoid
generated
vendored
Normal file
16
frontend/node_modules/.bin/nanoid
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/nanoid.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/nanoid.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.cjs" %*
|
||||
28
frontend/node_modules/.bin/nanoid.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/nanoid.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../nanoid/bin/nanoid.cjs" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/next
generated
vendored
Normal file
16
frontend/node_modules/.bin/next
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
|
||||
else
|
||||
exec node "$basedir/../next/dist/bin/next" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/next.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/next.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\next\dist\bin\next" %*
|
||||
28
frontend/node_modules/.bin/next.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/next.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../next/dist/bin/next" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../next/dist/bin/next" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../next/dist/bin/next" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../next/dist/bin/next" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/resolve
generated
vendored
Normal file
16
frontend/node_modules/.bin/resolve
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../resolve/bin/resolve" "$@"
|
||||
else
|
||||
exec node "$basedir/../resolve/bin/resolve" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/resolve.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/resolve.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\resolve\bin\resolve" %*
|
||||
28
frontend/node_modules/.bin/resolve.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/resolve.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../resolve/bin/resolve" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../resolve/bin/resolve" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../resolve/bin/resolve" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../resolve/bin/resolve" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/semver
generated
vendored
Normal file
16
frontend/node_modules/.bin/semver
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../semver/bin/semver.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../semver/bin/semver.js" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/semver.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/semver.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\semver\bin\semver.js" %*
|
||||
28
frontend/node_modules/.bin/semver.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/semver.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../semver/bin/semver.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../semver/bin/semver.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../semver/bin/semver.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../semver/bin/semver.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/sucrase
generated
vendored
Normal file
16
frontend/node_modules/.bin/sucrase
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../sucrase/bin/sucrase" "$@"
|
||||
else
|
||||
exec node "$basedir/../sucrase/bin/sucrase" "$@"
|
||||
fi
|
||||
16
frontend/node_modules/.bin/sucrase-node
generated
vendored
Normal file
16
frontend/node_modules/.bin/sucrase-node
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../sucrase/bin/sucrase-node" "$@"
|
||||
else
|
||||
exec node "$basedir/../sucrase/bin/sucrase-node" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/sucrase-node.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/sucrase-node.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\sucrase\bin\sucrase-node" %*
|
||||
28
frontend/node_modules/.bin/sucrase-node.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/sucrase-node.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../sucrase/bin/sucrase-node" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../sucrase/bin/sucrase-node" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../sucrase/bin/sucrase-node" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../sucrase/bin/sucrase-node" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
17
frontend/node_modules/.bin/sucrase.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/sucrase.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\sucrase\bin\sucrase" %*
|
||||
28
frontend/node_modules/.bin/sucrase.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/sucrase.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../sucrase/bin/sucrase" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../sucrase/bin/sucrase" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../sucrase/bin/sucrase" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../sucrase/bin/sucrase" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/tailwind
generated
vendored
Normal file
16
frontend/node_modules/.bin/tailwind
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../tailwindcss/lib/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../tailwindcss/lib/cli.js" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/tailwind.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/tailwind.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\tailwindcss\lib\cli.js" %*
|
||||
28
frontend/node_modules/.bin/tailwind.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/tailwind.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/tailwindcss
generated
vendored
Normal file
16
frontend/node_modules/.bin/tailwindcss
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../tailwindcss/lib/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../tailwindcss/lib/cli.js" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/tailwindcss.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/tailwindcss.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\tailwindcss\lib\cli.js" %*
|
||||
28
frontend/node_modules/.bin/tailwindcss.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/tailwindcss.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../tailwindcss/lib/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/tsc
generated
vendored
Normal file
16
frontend/node_modules/.bin/tsc
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/tsc.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/tsc.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\typescript\bin\tsc" %*
|
||||
28
frontend/node_modules/.bin/tsc.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/tsc.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../typescript/bin/tsc" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/tsserver
generated
vendored
Normal file
16
frontend/node_modules/.bin/tsserver
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/tsserver.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/tsserver.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\typescript\bin\tsserver" %*
|
||||
28
frontend/node_modules/.bin/tsserver.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/tsserver.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/update-browserslist-db
generated
vendored
Normal file
16
frontend/node_modules/.bin/update-browserslist-db
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../update-browserslist-db/cli.js" "$@"
|
||||
else
|
||||
exec node "$basedir/../update-browserslist-db/cli.js" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/update-browserslist-db.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/update-browserslist-db.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\update-browserslist-db\cli.js" %*
|
||||
28
frontend/node_modules/.bin/update-browserslist-db.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/update-browserslist-db.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../update-browserslist-db/cli.js" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../update-browserslist-db/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../update-browserslist-db/cli.js" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../update-browserslist-db/cli.js" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
16
frontend/node_modules/.bin/yaml
generated
vendored
Normal file
16
frontend/node_modules/.bin/yaml
generated
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*|*MINGW*|*MSYS*)
|
||||
if command -v cygpath > /dev/null 2>&1; then
|
||||
basedir=`cygpath -w "$basedir"`
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../yaml/bin.mjs" "$@"
|
||||
else
|
||||
exec node "$basedir/../yaml/bin.mjs" "$@"
|
||||
fi
|
||||
17
frontend/node_modules/.bin/yaml.cmd
generated
vendored
Normal file
17
frontend/node_modules/.bin/yaml.cmd
generated
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
@ECHO off
|
||||
GOTO start
|
||||
:find_dp0
|
||||
SET dp0=%~dp0
|
||||
EXIT /b
|
||||
:start
|
||||
SETLOCAL
|
||||
CALL :find_dp0
|
||||
|
||||
IF EXIST "%dp0%\node.exe" (
|
||||
SET "_prog=%dp0%\node.exe"
|
||||
) ELSE (
|
||||
SET "_prog=node"
|
||||
SET PATHEXT=%PATHEXT:;.JS;=;%
|
||||
)
|
||||
|
||||
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\yaml\bin.mjs" %*
|
||||
28
frontend/node_modules/.bin/yaml.ps1
generated
vendored
Normal file
28
frontend/node_modules/.bin/yaml.ps1
generated
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env pwsh
|
||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
||||
|
||||
$exe=""
|
||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
||||
# Fix case when both the Windows and Linux builds of Node
|
||||
# are installed in the same directory
|
||||
$exe=".exe"
|
||||
}
|
||||
$ret=0
|
||||
if (Test-Path "$basedir/node$exe") {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "$basedir/node$exe" "$basedir/../yaml/bin.mjs" $args
|
||||
} else {
|
||||
& "$basedir/node$exe" "$basedir/../yaml/bin.mjs" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
} else {
|
||||
# Support pipeline input
|
||||
if ($MyInvocation.ExpectingInput) {
|
||||
$input | & "node$exe" "$basedir/../yaml/bin.mjs" $args
|
||||
} else {
|
||||
& "node$exe" "$basedir/../yaml/bin.mjs" $args
|
||||
}
|
||||
$ret=$LASTEXITCODE
|
||||
}
|
||||
exit $ret
|
||||
1746
frontend/node_modules/.package-lock.json
generated
vendored
Normal file
1746
frontend/node_modules/.package-lock.json
generated
vendored
Normal file
File diff suppressed because it is too large
Load diff
128
frontend/node_modules/@alloc/quick-lru/index.d.ts
generated
vendored
Normal file
128
frontend/node_modules/@alloc/quick-lru/index.d.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
declare namespace QuickLRU {
|
||||
interface Options<KeyType, ValueType> {
|
||||
/**
|
||||
The maximum number of milliseconds an item should remain in the cache.
|
||||
|
||||
@default Infinity
|
||||
|
||||
By default, `maxAge` will be `Infinity`, which means that items will never expire.
|
||||
Lazy expiration upon the next write or read call.
|
||||
|
||||
Individual expiration of an item can be specified by the `set(key, value, maxAge)` method.
|
||||
*/
|
||||
readonly maxAge?: number;
|
||||
|
||||
/**
|
||||
The maximum number of items before evicting the least recently used items.
|
||||
*/
|
||||
readonly maxSize: number;
|
||||
|
||||
/**
|
||||
Called right before an item is evicted from the cache.
|
||||
|
||||
Useful for side effects or for items like object URLs that need explicit cleanup (`revokeObjectURL`).
|
||||
*/
|
||||
onEviction?: (key: KeyType, value: ValueType) => void;
|
||||
}
|
||||
}
|
||||
|
||||
declare class QuickLRU<KeyType, ValueType>
|
||||
implements Iterable<[KeyType, ValueType]> {
|
||||
/**
|
||||
The stored item count.
|
||||
*/
|
||||
readonly size: number;
|
||||
|
||||
/**
|
||||
Simple ["Least Recently Used" (LRU) cache](https://en.m.wikipedia.org/wiki/Cache_replacement_policies#Least_Recently_Used_.28LRU.29).
|
||||
|
||||
The instance is [`iterable`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols) so you can use it directly in a [`for…of`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/for...of) loop.
|
||||
|
||||
@example
|
||||
```
|
||||
import QuickLRU = require('quick-lru');
|
||||
|
||||
const lru = new QuickLRU({maxSize: 1000});
|
||||
|
||||
lru.set('🦄', '🌈');
|
||||
|
||||
lru.has('🦄');
|
||||
//=> true
|
||||
|
||||
lru.get('🦄');
|
||||
//=> '🌈'
|
||||
```
|
||||
*/
|
||||
constructor(options: QuickLRU.Options<KeyType, ValueType>);
|
||||
|
||||
[Symbol.iterator](): IterableIterator<[KeyType, ValueType]>;
|
||||
|
||||
/**
|
||||
Set an item. Returns the instance.
|
||||
|
||||
Individual expiration of an item can be specified with the `maxAge` option. If not specified, the global `maxAge` value will be used in case it is specified in the constructor, otherwise the item will never expire.
|
||||
|
||||
@returns The list instance.
|
||||
*/
|
||||
set(key: KeyType, value: ValueType, options?: {maxAge?: number}): this;
|
||||
|
||||
/**
|
||||
Get an item.
|
||||
|
||||
@returns The stored item or `undefined`.
|
||||
*/
|
||||
get(key: KeyType): ValueType | undefined;
|
||||
|
||||
/**
|
||||
Check if an item exists.
|
||||
*/
|
||||
has(key: KeyType): boolean;
|
||||
|
||||
/**
|
||||
Get an item without marking it as recently used.
|
||||
|
||||
@returns The stored item or `undefined`.
|
||||
*/
|
||||
peek(key: KeyType): ValueType | undefined;
|
||||
|
||||
/**
|
||||
Delete an item.
|
||||
|
||||
@returns `true` if the item is removed or `false` if the item doesn't exist.
|
||||
*/
|
||||
delete(key: KeyType): boolean;
|
||||
|
||||
/**
|
||||
Delete all items.
|
||||
*/
|
||||
clear(): void;
|
||||
|
||||
/**
|
||||
Update the `maxSize` in-place, discarding items as necessary. Insertion order is mostly preserved, though this is not a strong guarantee.
|
||||
|
||||
Useful for on-the-fly tuning of cache sizes in live systems.
|
||||
*/
|
||||
resize(maxSize: number): void;
|
||||
|
||||
/**
|
||||
Iterable for all the keys.
|
||||
*/
|
||||
keys(): IterableIterator<KeyType>;
|
||||
|
||||
/**
|
||||
Iterable for all the values.
|
||||
*/
|
||||
values(): IterableIterator<ValueType>;
|
||||
|
||||
/**
|
||||
Iterable for all entries, starting with the oldest (ascending in recency).
|
||||
*/
|
||||
entriesAscending(): IterableIterator<[KeyType, ValueType]>;
|
||||
|
||||
/**
|
||||
Iterable for all entries, starting with the newest (descending in recency).
|
||||
*/
|
||||
entriesDescending(): IterableIterator<[KeyType, ValueType]>;
|
||||
}
|
||||
|
||||
export = QuickLRU;
|
||||
263
frontend/node_modules/@alloc/quick-lru/index.js
generated
vendored
Normal file
263
frontend/node_modules/@alloc/quick-lru/index.js
generated
vendored
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
'use strict';
|
||||
|
||||
class QuickLRU {
|
||||
constructor(options = {}) {
|
||||
if (!(options.maxSize && options.maxSize > 0)) {
|
||||
throw new TypeError('`maxSize` must be a number greater than 0');
|
||||
}
|
||||
|
||||
if (typeof options.maxAge === 'number' && options.maxAge === 0) {
|
||||
throw new TypeError('`maxAge` must be a number greater than 0');
|
||||
}
|
||||
|
||||
this.maxSize = options.maxSize;
|
||||
this.maxAge = options.maxAge || Infinity;
|
||||
this.onEviction = options.onEviction;
|
||||
this.cache = new Map();
|
||||
this.oldCache = new Map();
|
||||
this._size = 0;
|
||||
}
|
||||
|
||||
_emitEvictions(cache) {
|
||||
if (typeof this.onEviction !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, item] of cache) {
|
||||
this.onEviction(key, item.value);
|
||||
}
|
||||
}
|
||||
|
||||
_deleteIfExpired(key, item) {
|
||||
if (typeof item.expiry === 'number' && item.expiry <= Date.now()) {
|
||||
if (typeof this.onEviction === 'function') {
|
||||
this.onEviction(key, item.value);
|
||||
}
|
||||
|
||||
return this.delete(key);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_getOrDeleteIfExpired(key, item) {
|
||||
const deleted = this._deleteIfExpired(key, item);
|
||||
if (deleted === false) {
|
||||
return item.value;
|
||||
}
|
||||
}
|
||||
|
||||
_getItemValue(key, item) {
|
||||
return item.expiry ? this._getOrDeleteIfExpired(key, item) : item.value;
|
||||
}
|
||||
|
||||
_peek(key, cache) {
|
||||
const item = cache.get(key);
|
||||
|
||||
return this._getItemValue(key, item);
|
||||
}
|
||||
|
||||
_set(key, value) {
|
||||
this.cache.set(key, value);
|
||||
this._size++;
|
||||
|
||||
if (this._size >= this.maxSize) {
|
||||
this._size = 0;
|
||||
this._emitEvictions(this.oldCache);
|
||||
this.oldCache = this.cache;
|
||||
this.cache = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
_moveToRecent(key, item) {
|
||||
this.oldCache.delete(key);
|
||||
this._set(key, item);
|
||||
}
|
||||
|
||||
* _entriesAscending() {
|
||||
for (const item of this.oldCache) {
|
||||
const [key, value] = item;
|
||||
if (!this.cache.has(key)) {
|
||||
const deleted = this._deleteIfExpired(key, value);
|
||||
if (deleted === false) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of this.cache) {
|
||||
const [key, value] = item;
|
||||
const deleted = this._deleteIfExpired(key, value);
|
||||
if (deleted === false) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if (this.cache.has(key)) {
|
||||
const item = this.cache.get(key);
|
||||
|
||||
return this._getItemValue(key, item);
|
||||
}
|
||||
|
||||
if (this.oldCache.has(key)) {
|
||||
const item = this.oldCache.get(key);
|
||||
if (this._deleteIfExpired(key, item) === false) {
|
||||
this._moveToRecent(key, item);
|
||||
return item.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(key, value, {maxAge = this.maxAge === Infinity ? undefined : Date.now() + this.maxAge} = {}) {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
maxAge
|
||||
});
|
||||
} else {
|
||||
this._set(key, {value, expiry: maxAge});
|
||||
}
|
||||
}
|
||||
|
||||
has(key) {
|
||||
if (this.cache.has(key)) {
|
||||
return !this._deleteIfExpired(key, this.cache.get(key));
|
||||
}
|
||||
|
||||
if (this.oldCache.has(key)) {
|
||||
return !this._deleteIfExpired(key, this.oldCache.get(key));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
peek(key) {
|
||||
if (this.cache.has(key)) {
|
||||
return this._peek(key, this.cache);
|
||||
}
|
||||
|
||||
if (this.oldCache.has(key)) {
|
||||
return this._peek(key, this.oldCache);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
const deleted = this.cache.delete(key);
|
||||
if (deleted) {
|
||||
this._size--;
|
||||
}
|
||||
|
||||
return this.oldCache.delete(key) || deleted;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear();
|
||||
this.oldCache.clear();
|
||||
this._size = 0;
|
||||
}
|
||||
|
||||
resize(newSize) {
|
||||
if (!(newSize && newSize > 0)) {
|
||||
throw new TypeError('`maxSize` must be a number greater than 0');
|
||||
}
|
||||
|
||||
const items = [...this._entriesAscending()];
|
||||
const removeCount = items.length - newSize;
|
||||
if (removeCount < 0) {
|
||||
this.cache = new Map(items);
|
||||
this.oldCache = new Map();
|
||||
this._size = items.length;
|
||||
} else {
|
||||
if (removeCount > 0) {
|
||||
this._emitEvictions(items.slice(0, removeCount));
|
||||
}
|
||||
|
||||
this.oldCache = new Map(items.slice(removeCount));
|
||||
this.cache = new Map();
|
||||
this._size = 0;
|
||||
}
|
||||
|
||||
this.maxSize = newSize;
|
||||
}
|
||||
|
||||
* keys() {
|
||||
for (const [key] of this) {
|
||||
yield key;
|
||||
}
|
||||
}
|
||||
|
||||
* values() {
|
||||
for (const [, value] of this) {
|
||||
yield value;
|
||||
}
|
||||
}
|
||||
|
||||
* [Symbol.iterator]() {
|
||||
for (const item of this.cache) {
|
||||
const [key, value] = item;
|
||||
const deleted = this._deleteIfExpired(key, value);
|
||||
if (deleted === false) {
|
||||
yield [key, value.value];
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of this.oldCache) {
|
||||
const [key, value] = item;
|
||||
if (!this.cache.has(key)) {
|
||||
const deleted = this._deleteIfExpired(key, value);
|
||||
if (deleted === false) {
|
||||
yield [key, value.value];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* entriesDescending() {
|
||||
let items = [...this.cache];
|
||||
for (let i = items.length - 1; i >= 0; --i) {
|
||||
const item = items[i];
|
||||
const [key, value] = item;
|
||||
const deleted = this._deleteIfExpired(key, value);
|
||||
if (deleted === false) {
|
||||
yield [key, value.value];
|
||||
}
|
||||
}
|
||||
|
||||
items = [...this.oldCache];
|
||||
for (let i = items.length - 1; i >= 0; --i) {
|
||||
const item = items[i];
|
||||
const [key, value] = item;
|
||||
if (!this.cache.has(key)) {
|
||||
const deleted = this._deleteIfExpired(key, value);
|
||||
if (deleted === false) {
|
||||
yield [key, value.value];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* entriesAscending() {
|
||||
for (const [key, value] of this._entriesAscending()) {
|
||||
yield [key, value.value];
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
if (!this._size) {
|
||||
return this.oldCache.size;
|
||||
}
|
||||
|
||||
let oldCacheSize = 0;
|
||||
for (const key of this.oldCache.keys()) {
|
||||
if (!this.cache.has(key)) {
|
||||
oldCacheSize++;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(this._size + oldCacheSize, this.maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QuickLRU;
|
||||
9
frontend/node_modules/@alloc/quick-lru/license
generated
vendored
Normal file
9
frontend/node_modules/@alloc/quick-lru/license
generated
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
43
frontend/node_modules/@alloc/quick-lru/package.json
generated
vendored
Normal file
43
frontend/node_modules/@alloc/quick-lru/package.json
generated
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "@alloc/quick-lru",
|
||||
"version": "5.2.0",
|
||||
"description": "Simple “Least Recently Used” (LRU) cache",
|
||||
"license": "MIT",
|
||||
"repository": "sindresorhus/quick-lru",
|
||||
"funding": "https://github.com/sponsors/sindresorhus",
|
||||
"author": {
|
||||
"name": "Sindre Sorhus",
|
||||
"email": "sindresorhus@gmail.com",
|
||||
"url": "https://sindresorhus.com"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "xo && nyc ava && tsd"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts"
|
||||
],
|
||||
"keywords": [
|
||||
"lru",
|
||||
"quick",
|
||||
"cache",
|
||||
"caching",
|
||||
"least",
|
||||
"recently",
|
||||
"used",
|
||||
"fast",
|
||||
"map",
|
||||
"hash",
|
||||
"buffer"
|
||||
],
|
||||
"devDependencies": {
|
||||
"ava": "^2.0.0",
|
||||
"coveralls": "^3.0.3",
|
||||
"nyc": "^15.0.0",
|
||||
"tsd": "^0.11.0",
|
||||
"xo": "^0.26.0"
|
||||
}
|
||||
}
|
||||
139
frontend/node_modules/@alloc/quick-lru/readme.md
generated
vendored
Normal file
139
frontend/node_modules/@alloc/quick-lru/readme.md
generated
vendored
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# quick-lru [](https://travis-ci.org/sindresorhus/quick-lru) [](https://coveralls.io/github/sindresorhus/quick-lru?branch=master)
|
||||
|
||||
> Simple [“Least Recently Used” (LRU) cache](https://en.m.wikipedia.org/wiki/Cache_replacement_policies#Least_Recently_Used_.28LRU.29)
|
||||
|
||||
Useful when you need to cache something and limit memory usage.
|
||||
|
||||
Inspired by the [`hashlru` algorithm](https://github.com/dominictarr/hashlru#algorithm), but instead uses [`Map`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Map) to support keys of any type, not just strings, and values can be `undefined`.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
$ npm install quick-lru
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const QuickLRU = require('quick-lru');
|
||||
|
||||
const lru = new QuickLRU({maxSize: 1000});
|
||||
|
||||
lru.set('🦄', '🌈');
|
||||
|
||||
lru.has('🦄');
|
||||
//=> true
|
||||
|
||||
lru.get('🦄');
|
||||
//=> '🌈'
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### new QuickLRU(options?)
|
||||
|
||||
Returns a new instance.
|
||||
|
||||
### options
|
||||
|
||||
Type: `object`
|
||||
|
||||
#### maxSize
|
||||
|
||||
*Required*\
|
||||
Type: `number`
|
||||
|
||||
The maximum number of items before evicting the least recently used items.
|
||||
|
||||
#### maxAge
|
||||
|
||||
Type: `number`\
|
||||
Default: `Infinity`
|
||||
|
||||
The maximum number of milliseconds an item should remain in cache.
|
||||
By default maxAge will be Infinity, which means that items will never expire.
|
||||
|
||||
Lazy expiration happens upon the next `write` or `read` call.
|
||||
|
||||
Individual expiration of an item can be specified by the `set(key, value, options)` method.
|
||||
|
||||
#### onEviction
|
||||
|
||||
*Optional*\
|
||||
Type: `(key, value) => void`
|
||||
|
||||
Called right before an item is evicted from the cache.
|
||||
|
||||
Useful for side effects or for items like object URLs that need explicit cleanup (`revokeObjectURL`).
|
||||
|
||||
### Instance
|
||||
|
||||
The instance is [`iterable`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Iteration_protocols) so you can use it directly in a [`for…of`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Statements/for...of) loop.
|
||||
|
||||
Both `key` and `value` can be of any type.
|
||||
|
||||
#### .set(key, value, options?)
|
||||
|
||||
Set an item. Returns the instance.
|
||||
|
||||
Individual expiration of an item can be specified with the `maxAge` option. If not specified, the global `maxAge` value will be used in case it is specified on the constructor, otherwise the item will never expire.
|
||||
|
||||
#### .get(key)
|
||||
|
||||
Get an item.
|
||||
|
||||
#### .has(key)
|
||||
|
||||
Check if an item exists.
|
||||
|
||||
#### .peek(key)
|
||||
|
||||
Get an item without marking it as recently used.
|
||||
|
||||
#### .delete(key)
|
||||
|
||||
Delete an item.
|
||||
|
||||
Returns `true` if the item is removed or `false` if the item doesn't exist.
|
||||
|
||||
#### .clear()
|
||||
|
||||
Delete all items.
|
||||
|
||||
#### .resize(maxSize)
|
||||
|
||||
Update the `maxSize`, discarding items as necessary. Insertion order is mostly preserved, though this is not a strong guarantee.
|
||||
|
||||
Useful for on-the-fly tuning of cache sizes in live systems.
|
||||
|
||||
#### .keys()
|
||||
|
||||
Iterable for all the keys.
|
||||
|
||||
#### .values()
|
||||
|
||||
Iterable for all the values.
|
||||
|
||||
#### .entriesAscending()
|
||||
|
||||
Iterable for all entries, starting with the oldest (ascending in recency).
|
||||
|
||||
#### .entriesDescending()
|
||||
|
||||
Iterable for all entries, starting with the newest (descending in recency).
|
||||
|
||||
#### .size
|
||||
|
||||
The stored item count.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<b>
|
||||
<a href="https://tidelift.com/subscription/pkg/npm-quick-lru?utm_source=npm-quick-lru&utm_medium=referral&utm_campaign=readme">Get professional support for this package with a Tidelift subscription</a>
|
||||
</b>
|
||||
<br>
|
||||
<sub>
|
||||
Tidelift helps make open source sustainable for maintainers while giving companies<br>assurances about security, maintenance, and licensing for their dependencies.
|
||||
</sub>
|
||||
</div>
|
||||
82
frontend/node_modules/@img/colour/LICENSE.md
generated
vendored
Normal file
82
frontend/node_modules/@img/colour/LICENSE.md
generated
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Licensing
|
||||
|
||||
## color
|
||||
|
||||
Copyright (c) 2012 Heather Arthur
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## color-convert
|
||||
|
||||
Copyright (c) 2011-2016 Heather Arthur <fayearthur@gmail.com>.
|
||||
Copyright (c) 2016-2021 Josh Junon <josh@junon.me>.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## color-string
|
||||
|
||||
Copyright (c) 2011 Heather Arthur <fayearthur@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## color-name
|
||||
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2015 Dmitry Ivanov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
15
frontend/node_modules/@img/colour/README.md
generated
vendored
Normal file
15
frontend/node_modules/@img/colour/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# `@img/colour`
|
||||
|
||||
The latest version of the
|
||||
[color](https://www.npmjs.com/package/color)
|
||||
package is now ESM-only,
|
||||
however some JavaScript runtimes do not yet support this,
|
||||
which includes versions of Node.js prior to 20.19.0.
|
||||
|
||||
This package converts the `color` package and its dependencies,
|
||||
all of which are MIT-licensed, to CommonJS.
|
||||
|
||||
- [color](https://www.npmjs.com/package/color)
|
||||
- [color-convert](https://www.npmjs.com/package/color-convert)
|
||||
- [color-string](https://www.npmjs.com/package/color-string)
|
||||
- [color-name](https://www.npmjs.com/package/color-name)
|
||||
1596
frontend/node_modules/@img/colour/color.cjs
generated
vendored
Normal file
1596
frontend/node_modules/@img/colour/color.cjs
generated
vendored
Normal file
File diff suppressed because it is too large
Load diff
1
frontend/node_modules/@img/colour/index.cjs
generated
vendored
Normal file
1
frontend/node_modules/@img/colour/index.cjs
generated
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
module.exports = require("./color.cjs").default;
|
||||
929
frontend/node_modules/@img/colour/index.d.ts
generated
vendored
Normal file
929
frontend/node_modules/@img/colour/index.d.ts
generated
vendored
Normal file
|
|
@ -0,0 +1,929 @@
|
|||
// Generated by dts-bundle-generator v9.5.1
|
||||
|
||||
type Channels = number;
|
||||
type RGB = [
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
];
|
||||
type HSL = [
|
||||
h: number,
|
||||
s: number,
|
||||
l: number
|
||||
];
|
||||
type HSV = [
|
||||
h: number,
|
||||
s: number,
|
||||
v: number
|
||||
];
|
||||
type CMYK = [
|
||||
c: number,
|
||||
m: number,
|
||||
y: number,
|
||||
k: number
|
||||
];
|
||||
type LAB = [
|
||||
l: number,
|
||||
a: number,
|
||||
b: number
|
||||
];
|
||||
type LCH = [
|
||||
l: number,
|
||||
c: number,
|
||||
h: number
|
||||
];
|
||||
type HCG = [
|
||||
h: number,
|
||||
c: number,
|
||||
g: number
|
||||
];
|
||||
type HWB = [
|
||||
h: number,
|
||||
w: number,
|
||||
b: number
|
||||
];
|
||||
type XYZ = [
|
||||
x: number,
|
||||
y: number,
|
||||
z: number
|
||||
];
|
||||
type Apple = [
|
||||
r16: number,
|
||||
g16: number,
|
||||
b16: number
|
||||
];
|
||||
type Gray = [
|
||||
gray: number
|
||||
];
|
||||
type ANSI16 = number;
|
||||
type ANSI256 = number;
|
||||
type Keyword = string;
|
||||
type HEX = string;
|
||||
declare namespace route {
|
||||
type rgb = {
|
||||
hsl(from: RGB): HSL;
|
||||
hsl(...from: RGB): HSL;
|
||||
hsl(from: RGB): HSL;
|
||||
hsl(...from: RGB): HSL;
|
||||
hsv(from: RGB): HSV;
|
||||
hsv(...from: RGB): HSV;
|
||||
hwb(from: RGB): HWB;
|
||||
hwb(...from: RGB): HWB;
|
||||
cmyk(from: RGB): CMYK;
|
||||
cmyk(...from: RGB): CMYK;
|
||||
xyz(from: RGB): XYZ;
|
||||
xyz(...from: RGB): XYZ;
|
||||
lab(from: RGB): LAB;
|
||||
lab(...from: RGB): LAB;
|
||||
lch(from: RGB): LCH;
|
||||
lch(...from: RGB): LCH;
|
||||
hex(from: RGB): HEX;
|
||||
hex(...from: RGB): HEX;
|
||||
keyword(from: RGB): Keyword;
|
||||
keyword(...from: RGB): Keyword;
|
||||
ansi16(from: RGB): ANSI16;
|
||||
ansi16(...from: RGB): ANSI16;
|
||||
ansi256(from: RGB): ANSI256;
|
||||
ansi256(...from: RGB): ANSI256;
|
||||
hcg(from: RGB): HCG;
|
||||
hcg(...from: RGB): HCG;
|
||||
apple(from: RGB): Apple;
|
||||
apple(...from: RGB): Apple;
|
||||
gray(from: RGB): Gray;
|
||||
gray(...from: RGB): Gray;
|
||||
};
|
||||
type hsl = {
|
||||
rgb(from: HSL): RGB;
|
||||
rgb(...from: HSL): RGB;
|
||||
hsv(from: HSL): HSV;
|
||||
hsv(...from: HSL): HSV;
|
||||
hwb(from: HSL): HWB;
|
||||
hwb(...from: HSL): HWB;
|
||||
cmyk(from: HSL): CMYK;
|
||||
cmyk(...from: HSL): CMYK;
|
||||
xyz(from: HSL): XYZ;
|
||||
xyz(...from: HSL): XYZ;
|
||||
lab(from: HSL): LAB;
|
||||
lab(...from: HSL): LAB;
|
||||
lch(from: HSL): LCH;
|
||||
lch(...from: HSL): LCH;
|
||||
hex(from: HSL): HEX;
|
||||
hex(...from: HSL): HEX;
|
||||
keyword(from: HSL): Keyword;
|
||||
keyword(...from: HSL): Keyword;
|
||||
ansi16(from: HSL): ANSI16;
|
||||
ansi16(...from: HSL): ANSI16;
|
||||
ansi256(from: HSL): ANSI256;
|
||||
ansi256(...from: HSL): ANSI256;
|
||||
hcg(from: HSL): HCG;
|
||||
hcg(...from: HSL): HCG;
|
||||
apple(from: HSL): Apple;
|
||||
apple(...from: HSL): Apple;
|
||||
gray(from: HSL): Gray;
|
||||
gray(...from: HSL): Gray;
|
||||
};
|
||||
type hsv = {
|
||||
rgb(from: HSV): RGB;
|
||||
rgb(...from: HSV): RGB;
|
||||
hsl(from: HSV): HSL;
|
||||
hsl(...from: HSV): HSL;
|
||||
hwb(from: HSV): HWB;
|
||||
hwb(...from: HSV): HWB;
|
||||
cmyk(from: HSV): CMYK;
|
||||
cmyk(...from: HSV): CMYK;
|
||||
xyz(from: HSV): XYZ;
|
||||
xyz(...from: HSV): XYZ;
|
||||
lab(from: HSV): LAB;
|
||||
lab(...from: HSV): LAB;
|
||||
lch(from: HSV): LCH;
|
||||
lch(...from: HSV): LCH;
|
||||
hex(from: HSV): HEX;
|
||||
hex(...from: HSV): HEX;
|
||||
keyword(from: HSV): Keyword;
|
||||
keyword(...from: HSV): Keyword;
|
||||
ansi16(from: HSV): ANSI16;
|
||||
ansi16(...from: HSV): ANSI16;
|
||||
ansi256(from: HSV): ANSI256;
|
||||
ansi256(...from: HSV): ANSI256;
|
||||
hcg(from: HSV): HCG;
|
||||
hcg(...from: HSV): HCG;
|
||||
apple(from: HSV): Apple;
|
||||
apple(...from: HSV): Apple;
|
||||
gray(from: HSV): Gray;
|
||||
gray(...from: HSV): Gray;
|
||||
};
|
||||
type hwb = {
|
||||
rgb(from: HWB): RGB;
|
||||
rgb(...from: HWB): RGB;
|
||||
hsl(from: HWB): HSL;
|
||||
hsl(...from: HWB): HSL;
|
||||
hsv(from: HWB): HSV;
|
||||
hsv(...from: HWB): HSV;
|
||||
cmyk(from: HWB): CMYK;
|
||||
cmyk(...from: HWB): CMYK;
|
||||
xyz(from: HWB): XYZ;
|
||||
xyz(...from: HWB): XYZ;
|
||||
lab(from: HWB): LAB;
|
||||
lab(...from: HWB): LAB;
|
||||
lch(from: HWB): LCH;
|
||||
lch(...from: HWB): LCH;
|
||||
hex(from: HWB): HEX;
|
||||
hex(...from: HWB): HEX;
|
||||
keyword(from: HWB): Keyword;
|
||||
keyword(...from: HWB): Keyword;
|
||||
ansi16(from: HWB): ANSI16;
|
||||
ansi16(...from: HWB): ANSI16;
|
||||
ansi256(from: HWB): ANSI256;
|
||||
ansi256(...from: HWB): ANSI256;
|
||||
hcg(from: HWB): HCG;
|
||||
hcg(...from: HWB): HCG;
|
||||
apple(from: HWB): Apple;
|
||||
apple(...from: HWB): Apple;
|
||||
gray(from: HWB): Gray;
|
||||
gray(...from: HWB): Gray;
|
||||
};
|
||||
type cmyk = {
|
||||
rgb(from: CMYK): RGB;
|
||||
rgb(...from: CMYK): RGB;
|
||||
hsl(from: CMYK): HSL;
|
||||
hsl(...from: CMYK): HSL;
|
||||
hsv(from: CMYK): HSV;
|
||||
hsv(...from: CMYK): HSV;
|
||||
hwb(from: CMYK): HWB;
|
||||
hwb(...from: CMYK): HWB;
|
||||
xyz(from: CMYK): XYZ;
|
||||
xyz(...from: CMYK): XYZ;
|
||||
lab(from: CMYK): LAB;
|
||||
lab(...from: CMYK): LAB;
|
||||
lch(from: CMYK): LCH;
|
||||
lch(...from: CMYK): LCH;
|
||||
hex(from: CMYK): HEX;
|
||||
hex(...from: CMYK): HEX;
|
||||
keyword(from: CMYK): Keyword;
|
||||
keyword(...from: CMYK): Keyword;
|
||||
ansi16(from: CMYK): ANSI16;
|
||||
ansi16(...from: CMYK): ANSI16;
|
||||
ansi256(from: CMYK): ANSI256;
|
||||
ansi256(...from: CMYK): ANSI256;
|
||||
hcg(from: CMYK): HCG;
|
||||
hcg(...from: CMYK): HCG;
|
||||
apple(from: CMYK): Apple;
|
||||
apple(...from: CMYK): Apple;
|
||||
gray(from: CMYK): Gray;
|
||||
gray(...from: CMYK): Gray;
|
||||
};
|
||||
type xyz = {
|
||||
rgb(from: XYZ): RGB;
|
||||
rgb(...from: XYZ): RGB;
|
||||
hsl(from: XYZ): HSL;
|
||||
hsl(...from: XYZ): HSL;
|
||||
hsv(from: XYZ): HSV;
|
||||
hsv(...from: XYZ): HSV;
|
||||
hwb(from: XYZ): HWB;
|
||||
hwb(...from: XYZ): HWB;
|
||||
cmyk(from: XYZ): CMYK;
|
||||
cmyk(...from: XYZ): CMYK;
|
||||
lab(from: XYZ): LAB;
|
||||
lab(...from: XYZ): LAB;
|
||||
lch(from: XYZ): LCH;
|
||||
lch(...from: XYZ): LCH;
|
||||
hex(from: XYZ): HEX;
|
||||
hex(...from: XYZ): HEX;
|
||||
keyword(from: XYZ): Keyword;
|
||||
keyword(...from: XYZ): Keyword;
|
||||
ansi16(from: XYZ): ANSI16;
|
||||
ansi16(...from: XYZ): ANSI16;
|
||||
ansi256(from: XYZ): ANSI256;
|
||||
ansi256(...from: XYZ): ANSI256;
|
||||
hcg(from: XYZ): HCG;
|
||||
hcg(...from: XYZ): HCG;
|
||||
apple(from: XYZ): Apple;
|
||||
apple(...from: XYZ): Apple;
|
||||
gray(from: XYZ): Gray;
|
||||
gray(...from: XYZ): Gray;
|
||||
};
|
||||
type lab = {
|
||||
rgb(from: LAB): RGB;
|
||||
rgb(...from: LAB): RGB;
|
||||
hsl(from: LAB): HSL;
|
||||
hsl(...from: LAB): HSL;
|
||||
hsv(from: LAB): HSV;
|
||||
hsv(...from: LAB): HSV;
|
||||
hwb(from: LAB): HWB;
|
||||
hwb(...from: LAB): HWB;
|
||||
cmyk(from: LAB): CMYK;
|
||||
cmyk(...from: LAB): CMYK;
|
||||
xyz(from: LAB): XYZ;
|
||||
xyz(...from: LAB): XYZ;
|
||||
lch(from: LAB): LCH;
|
||||
lch(...from: LAB): LCH;
|
||||
hex(from: LAB): HEX;
|
||||
hex(...from: LAB): HEX;
|
||||
keyword(from: LAB): Keyword;
|
||||
keyword(...from: LAB): Keyword;
|
||||
ansi16(from: LAB): ANSI16;
|
||||
ansi16(...from: LAB): ANSI16;
|
||||
ansi256(from: LAB): ANSI256;
|
||||
ansi256(...from: LAB): ANSI256;
|
||||
hcg(from: LAB): HCG;
|
||||
hcg(...from: LAB): HCG;
|
||||
apple(from: LAB): Apple;
|
||||
apple(...from: LAB): Apple;
|
||||
gray(from: LAB): Gray;
|
||||
gray(...from: LAB): Gray;
|
||||
};
|
||||
type lch = {
|
||||
rgb(from: LCH): RGB;
|
||||
rgb(...from: LCH): RGB;
|
||||
hsl(from: LCH): HSL;
|
||||
hsl(...from: LCH): HSL;
|
||||
hsv(from: LCH): HSV;
|
||||
hsv(...from: LCH): HSV;
|
||||
hwb(from: LCH): HWB;
|
||||
hwb(...from: LCH): HWB;
|
||||
cmyk(from: LCH): CMYK;
|
||||
cmyk(...from: LCH): CMYK;
|
||||
xyz(from: LCH): XYZ;
|
||||
xyz(...from: LCH): XYZ;
|
||||
lab(from: LCH): LAB;
|
||||
lab(...from: LCH): LAB;
|
||||
hex(from: LCH): HEX;
|
||||
hex(...from: LCH): HEX;
|
||||
keyword(from: LCH): Keyword;
|
||||
keyword(...from: LCH): Keyword;
|
||||
ansi16(from: LCH): ANSI16;
|
||||
ansi16(...from: LCH): ANSI16;
|
||||
ansi256(from: LCH): ANSI256;
|
||||
ansi256(...from: LCH): ANSI256;
|
||||
hcg(from: LCH): HCG;
|
||||
hcg(...from: LCH): HCG;
|
||||
apple(from: LCH): Apple;
|
||||
apple(...from: LCH): Apple;
|
||||
gray(from: LCH): Gray;
|
||||
gray(...from: LCH): Gray;
|
||||
};
|
||||
type hex = {
|
||||
rgb(from: HEX): RGB;
|
||||
hsl(from: HEX): HSL;
|
||||
hsv(from: HEX): HSV;
|
||||
hwb(from: HEX): HWB;
|
||||
cmyk(from: HEX): CMYK;
|
||||
xyz(from: HEX): XYZ;
|
||||
lab(from: HEX): LAB;
|
||||
lch(from: HEX): LCH;
|
||||
keyword(from: HEX): Keyword;
|
||||
ansi16(from: HEX): ANSI16;
|
||||
ansi256(from: HEX): ANSI256;
|
||||
hcg(from: HEX): HCG;
|
||||
apple(from: HEX): Apple;
|
||||
gray(from: HEX): Gray;
|
||||
};
|
||||
type keyword = {
|
||||
rgb(from: Keyword): RGB;
|
||||
hsl(from: Keyword): HSL;
|
||||
hsv(from: Keyword): HSV;
|
||||
hwb(from: Keyword): HWB;
|
||||
cmyk(from: Keyword): CMYK;
|
||||
xyz(from: Keyword): XYZ;
|
||||
lab(from: Keyword): LAB;
|
||||
lch(from: Keyword): LCH;
|
||||
hex(from: Keyword): HEX;
|
||||
ansi16(from: Keyword): ANSI16;
|
||||
ansi256(from: Keyword): ANSI256;
|
||||
hcg(from: Keyword): HCG;
|
||||
apple(from: Keyword): Apple;
|
||||
gray(from: Keyword): Gray;
|
||||
};
|
||||
type ansi16 = {
|
||||
rgb(from: ANSI16): RGB;
|
||||
hsl(from: ANSI16): HSL;
|
||||
hsv(from: ANSI16): HSV;
|
||||
hwb(from: ANSI16): HWB;
|
||||
cmyk(from: ANSI16): CMYK;
|
||||
xyz(from: ANSI16): XYZ;
|
||||
lab(from: ANSI16): LAB;
|
||||
lch(from: ANSI16): LCH;
|
||||
hex(from: ANSI16): HEX;
|
||||
keyword(from: ANSI16): Keyword;
|
||||
ansi256(from: ANSI16): ANSI256;
|
||||
hcg(from: ANSI16): HCG;
|
||||
apple(from: ANSI16): Apple;
|
||||
gray(from: ANSI16): Gray;
|
||||
};
|
||||
type ansi256 = {
|
||||
rgb(from: ANSI256): RGB;
|
||||
hsl(from: ANSI256): HSL;
|
||||
hsv(from: ANSI256): HSV;
|
||||
hwb(from: ANSI256): HWB;
|
||||
cmyk(from: ANSI256): CMYK;
|
||||
xyz(from: ANSI256): XYZ;
|
||||
lab(from: ANSI256): LAB;
|
||||
lch(from: ANSI256): LCH;
|
||||
hex(from: ANSI256): HEX;
|
||||
keyword(from: ANSI256): Keyword;
|
||||
ansi16(from: ANSI256): ANSI16;
|
||||
hcg(from: ANSI256): HCG;
|
||||
apple(from: ANSI256): Apple;
|
||||
gray(from: ANSI256): Gray;
|
||||
};
|
||||
type hcg = {
|
||||
rgb(from: HCG): RGB;
|
||||
rgb(...from: HCG): RGB;
|
||||
hsl(from: HCG): HSL;
|
||||
hsl(...from: HCG): HSL;
|
||||
hsv(from: HCG): HSV;
|
||||
hsv(...from: HCG): HSV;
|
||||
hwb(from: HCG): HWB;
|
||||
hwb(...from: HCG): HWB;
|
||||
cmyk(from: HCG): CMYK;
|
||||
cmyk(...from: HCG): CMYK;
|
||||
xyz(from: HCG): XYZ;
|
||||
xyz(...from: HCG): XYZ;
|
||||
lab(from: HCG): LAB;
|
||||
lab(...from: HCG): LAB;
|
||||
lch(from: HCG): LCH;
|
||||
lch(...from: HCG): LCH;
|
||||
hex(from: HCG): HEX;
|
||||
hex(...from: HCG): HEX;
|
||||
keyword(from: HCG): Keyword;
|
||||
keyword(...from: HCG): Keyword;
|
||||
ansi16(from: HCG): ANSI16;
|
||||
ansi16(...from: HCG): ANSI16;
|
||||
ansi256(from: HCG): ANSI256;
|
||||
ansi256(...from: HCG): ANSI256;
|
||||
apple(from: HCG): Apple;
|
||||
apple(...from: HCG): Apple;
|
||||
gray(from: HCG): Gray;
|
||||
gray(...from: HCG): Gray;
|
||||
};
|
||||
type apple = {
|
||||
rgb(from: Apple): RGB;
|
||||
rgb(...from: Apple): RGB;
|
||||
hsl(from: Apple): HSL;
|
||||
hsl(...from: Apple): HSL;
|
||||
hsv(from: Apple): HSV;
|
||||
hsv(...from: Apple): HSV;
|
||||
hwb(from: Apple): HWB;
|
||||
hwb(...from: Apple): HWB;
|
||||
cmyk(from: Apple): CMYK;
|
||||
cmyk(...from: Apple): CMYK;
|
||||
xyz(from: Apple): XYZ;
|
||||
xyz(...from: Apple): XYZ;
|
||||
lab(from: Apple): LAB;
|
||||
lab(...from: Apple): LAB;
|
||||
lch(from: Apple): LCH;
|
||||
lch(...from: Apple): LCH;
|
||||
hex(from: Apple): HEX;
|
||||
hex(...from: Apple): HEX;
|
||||
keyword(from: Apple): Keyword;
|
||||
keyword(...from: Apple): Keyword;
|
||||
ansi16(from: Apple): ANSI16;
|
||||
ansi16(...from: Apple): ANSI16;
|
||||
ansi256(from: Apple): ANSI256;
|
||||
ansi256(...from: Apple): ANSI256;
|
||||
hcg(from: Apple): HCG;
|
||||
hcg(...from: Apple): HCG;
|
||||
gray(from: Apple): Gray;
|
||||
gray(...from: Apple): Gray;
|
||||
};
|
||||
type gray = {
|
||||
rgb(from: Gray): RGB;
|
||||
rgb(...from: Gray): RGB;
|
||||
hsl(from: Gray): HSL;
|
||||
hsl(...from: Gray): HSL;
|
||||
hsv(from: Gray): HSV;
|
||||
hsv(...from: Gray): HSV;
|
||||
hwb(from: Gray): HWB;
|
||||
hwb(...from: Gray): HWB;
|
||||
cmyk(from: Gray): CMYK;
|
||||
cmyk(...from: Gray): CMYK;
|
||||
xyz(from: Gray): XYZ;
|
||||
xyz(...from: Gray): XYZ;
|
||||
lab(from: Gray): LAB;
|
||||
lab(...from: Gray): LAB;
|
||||
lch(from: Gray): LCH;
|
||||
lch(...from: Gray): LCH;
|
||||
hex(from: Gray): HEX;
|
||||
hex(...from: Gray): HEX;
|
||||
keyword(from: Gray): Keyword;
|
||||
keyword(...from: Gray): Keyword;
|
||||
ansi16(from: Gray): ANSI16;
|
||||
ansi16(...from: Gray): ANSI16;
|
||||
ansi256(from: Gray): ANSI256;
|
||||
ansi256(...from: Gray): ANSI256;
|
||||
hcg(from: Gray): HCG;
|
||||
hcg(...from: Gray): HCG;
|
||||
apple(from: Gray): Apple;
|
||||
apple(...from: Gray): Apple;
|
||||
};
|
||||
}
|
||||
declare function route(fromModel: "rgb"): route.rgb;
|
||||
declare function route(fromModel: "hsl"): route.hsl;
|
||||
declare function route(fromModel: "hsv"): route.hsv;
|
||||
declare function route(fromModel: "hwb"): route.hwb;
|
||||
declare function route(fromModel: "cmyk"): route.cmyk;
|
||||
declare function route(fromModel: "xyz"): route.xyz;
|
||||
declare function route(fromModel: "lab"): route.lab;
|
||||
declare function route(fromModel: "lch"): route.lch;
|
||||
declare function route(fromModel: "hex"): route.hex;
|
||||
declare function route(fromModel: "keyword"): route.keyword;
|
||||
declare function route(fromModel: "ansi16"): route.ansi16;
|
||||
declare function route(fromModel: "ansi256"): route.ansi256;
|
||||
declare function route(fromModel: "hcg"): route.hcg;
|
||||
declare function route(fromModel: "apple"): route.apple;
|
||||
declare function route(fromModel: "gray"): route.gray;
|
||||
type Convert = {
|
||||
rgb: {
|
||||
channels: Channels;
|
||||
labels: "rgb";
|
||||
hsl: {
|
||||
(...rgb: RGB): HSL;
|
||||
raw: (...rgb: RGB) => HSL;
|
||||
};
|
||||
hsv: {
|
||||
(...rgb: RGB): HSV;
|
||||
raw: (...rgb: RGB) => HSV;
|
||||
};
|
||||
hwb: {
|
||||
(...rgb: RGB): HWB;
|
||||
raw: (...rgb: RGB) => HWB;
|
||||
};
|
||||
hcg: {
|
||||
(...rgb: RGB): HCG;
|
||||
raw: (...rgb: RGB) => HCG;
|
||||
};
|
||||
cmyk: {
|
||||
(...rgb: RGB): CMYK;
|
||||
raw: (...rgb: RGB) => CMYK;
|
||||
};
|
||||
keyword: {
|
||||
(...rgb: RGB): Keyword;
|
||||
raw: (...rgb: RGB) => Keyword;
|
||||
};
|
||||
ansi16: {
|
||||
(...rgb: RGB): ANSI16;
|
||||
raw: (...rgb: RGB) => ANSI16;
|
||||
};
|
||||
ansi256: {
|
||||
(...rgb: RGB): ANSI256;
|
||||
raw: (...rgb: RGB) => ANSI256;
|
||||
};
|
||||
apple: {
|
||||
(...rgb: RGB): Apple;
|
||||
raw: (...rgb: RGB) => Apple;
|
||||
};
|
||||
hex: {
|
||||
(...rgb: RGB): HEX;
|
||||
raw: (...rgb: RGB) => HEX;
|
||||
};
|
||||
gray: {
|
||||
(...rgb: RGB): Gray;
|
||||
raw: (...rgb: RGB) => Gray;
|
||||
};
|
||||
} & route.rgb & {
|
||||
[F in keyof route.rgb]: {
|
||||
raw: route.rgb[F];
|
||||
};
|
||||
};
|
||||
keyword: {
|
||||
channels: Channels;
|
||||
rgb: {
|
||||
(keyword: Keyword): RGB;
|
||||
raw: (keyword: Keyword) => RGB;
|
||||
};
|
||||
} & route.keyword & {
|
||||
[F in keyof route.keyword]: {
|
||||
raw: route.keyword[F];
|
||||
};
|
||||
};
|
||||
hsl: {
|
||||
channels: Channels;
|
||||
labels: "hsl";
|
||||
rgb: {
|
||||
(...hsl: HSL): RGB;
|
||||
raw: (...hsl: HSL) => RGB;
|
||||
};
|
||||
hsv: {
|
||||
(...hsl: HSL): HSV;
|
||||
raw: (...hsl: HSL) => HSV;
|
||||
};
|
||||
hcg: {
|
||||
(...hsl: HSL): HCG;
|
||||
raw: (...hsl: HSL) => HCG;
|
||||
};
|
||||
} & route.hsl & {
|
||||
[F in keyof route.hsl]: {
|
||||
raw: route.hsl[F];
|
||||
};
|
||||
};
|
||||
hsv: {
|
||||
channels: Channels;
|
||||
labels: "hsv";
|
||||
hcg: {
|
||||
(...hsv: HSV): HCG;
|
||||
raw: (...hsv: HSV) => HCG;
|
||||
};
|
||||
rgb: {
|
||||
(...hsv: HSV): RGB;
|
||||
raw: (...hsv: HSV) => RGB;
|
||||
};
|
||||
hsv: {
|
||||
(...hsv: HSV): HSV;
|
||||
raw: (...hsv: HSV) => HSV;
|
||||
};
|
||||
hsl: {
|
||||
(...hsv: HSV): HSL;
|
||||
raw: (...hsv: HSV) => HSL;
|
||||
};
|
||||
hwb: {
|
||||
(...hsv: HSV): HWB;
|
||||
raw: (...hsv: HSV) => HWB;
|
||||
};
|
||||
ansi16: {
|
||||
(...hsv: HSV): ANSI16;
|
||||
raw: (...hsv: HSV) => ANSI16;
|
||||
};
|
||||
} & route.hsv & {
|
||||
[F in keyof route.hsv]: {
|
||||
raw: route.hsv[F];
|
||||
};
|
||||
};
|
||||
hwb: {
|
||||
channels: Channels;
|
||||
labels: "hwb";
|
||||
hcg: {
|
||||
(...hwb: HWB): HCG;
|
||||
raw: (...hwb: HWB) => HCG;
|
||||
};
|
||||
rgb: {
|
||||
(...hwb: HWB): RGB;
|
||||
raw: (...hwb: HWB) => RGB;
|
||||
};
|
||||
} & route.hwb & {
|
||||
[F in keyof route.hwb]: {
|
||||
raw: route.hwb[F];
|
||||
};
|
||||
};
|
||||
cmyk: {
|
||||
channels: Channels;
|
||||
labels: "cmyk";
|
||||
rgb: {
|
||||
(...cmyk: CMYK): RGB;
|
||||
raw: (...cmyk: CMYK) => RGB;
|
||||
};
|
||||
} & route.cmyk & {
|
||||
[F in keyof route.cmyk]: {
|
||||
raw: route.cmyk[F];
|
||||
};
|
||||
};
|
||||
xyz: {
|
||||
channels: Channels;
|
||||
labels: "xyz";
|
||||
rgb: {
|
||||
(...xyz: XYZ): RGB;
|
||||
raw: (...xyz: XYZ) => RGB;
|
||||
};
|
||||
lab: {
|
||||
(...xyz: XYZ): LAB;
|
||||
raw: (...xyz: XYZ) => LAB;
|
||||
};
|
||||
} & route.xyz & {
|
||||
[F in keyof route.xyz]: {
|
||||
raw: route.xyz[F];
|
||||
};
|
||||
};
|
||||
lab: {
|
||||
channels: Channels;
|
||||
labels: "lab";
|
||||
xyz: {
|
||||
(...lab: LAB): XYZ;
|
||||
raw: (...lab: LAB) => XYZ;
|
||||
};
|
||||
lch: {
|
||||
(...lab: LAB): LCH;
|
||||
raw: (...lab: LAB) => LCH;
|
||||
};
|
||||
} & route.lab & {
|
||||
[F in keyof route.lab]: {
|
||||
raw: route.lab[F];
|
||||
};
|
||||
};
|
||||
lch: {
|
||||
channels: Channels;
|
||||
labels: "lch";
|
||||
lab: {
|
||||
(...lch: LCH): LAB;
|
||||
raw: (...lch: LCH) => LAB;
|
||||
};
|
||||
} & route.lch & {
|
||||
[F in keyof route.lch]: {
|
||||
raw: route.lch[F];
|
||||
};
|
||||
};
|
||||
hex: {
|
||||
channels: Channels;
|
||||
labels: [
|
||||
"hex"
|
||||
];
|
||||
rgb: {
|
||||
(hex: HEX): RGB;
|
||||
raw: (hex: HEX) => RGB;
|
||||
};
|
||||
} & route.hex & {
|
||||
[F in keyof route.hex]: {
|
||||
raw: route.hex[F];
|
||||
};
|
||||
};
|
||||
ansi16: {
|
||||
channels: Channels;
|
||||
labels: [
|
||||
"ansi16"
|
||||
];
|
||||
rgb: {
|
||||
(ansi16: ANSI16): RGB;
|
||||
raw: (ansi16: ANSI16) => RGB;
|
||||
};
|
||||
} & route.ansi16 & {
|
||||
[F in keyof route.ansi16]: {
|
||||
raw: route.ansi16[F];
|
||||
};
|
||||
};
|
||||
ansi256: {
|
||||
channels: Channels;
|
||||
labels: [
|
||||
"ansi256"
|
||||
];
|
||||
rgb: {
|
||||
(ansi256: ANSI256): RGB;
|
||||
raw: (ansi256: ANSI256) => RGB;
|
||||
};
|
||||
} & route.ansi256 & {
|
||||
[F in keyof route.ansi256]: {
|
||||
raw: route.ansi256[F];
|
||||
};
|
||||
};
|
||||
hcg: {
|
||||
channels: Channels;
|
||||
labels: [
|
||||
"h",
|
||||
"c",
|
||||
"g"
|
||||
];
|
||||
rgb: {
|
||||
(...hcg: HCG): RGB;
|
||||
raw: (...hcg: HCG) => RGB;
|
||||
};
|
||||
hsv: {
|
||||
(...hcg: HCG): HSV;
|
||||
raw: (...hcg: HCG) => HSV;
|
||||
};
|
||||
hwb: {
|
||||
(...hcg: HCG): HWB;
|
||||
raw: (...hcg: HCG) => HWB;
|
||||
};
|
||||
} & route.hcg & {
|
||||
[F in keyof route.hcg]: {
|
||||
raw: route.hcg[F];
|
||||
};
|
||||
};
|
||||
apple: {
|
||||
channels: Channels;
|
||||
labels: [
|
||||
"r16",
|
||||
"g16",
|
||||
"b16"
|
||||
];
|
||||
rgb: {
|
||||
(...apple: Apple): RGB;
|
||||
raw: (...apple: Apple) => RGB;
|
||||
};
|
||||
} & route.apple & {
|
||||
[F in keyof route.apple]: {
|
||||
raw: route.apple[F];
|
||||
};
|
||||
};
|
||||
gray: {
|
||||
channels: Channels;
|
||||
labels: [
|
||||
"gray"
|
||||
];
|
||||
rgb: {
|
||||
(...gray: Gray): RGB;
|
||||
raw: (...gray: Gray) => RGB;
|
||||
};
|
||||
hsl: {
|
||||
(...gray: Gray): HSL;
|
||||
raw: (...gray: Gray) => HSL;
|
||||
};
|
||||
hsv: {
|
||||
(...gray: Gray): HSV;
|
||||
raw: (...gray: Gray) => HSV;
|
||||
};
|
||||
hwb: {
|
||||
(...gray: Gray): HWB;
|
||||
raw: (...gray: Gray) => HWB;
|
||||
};
|
||||
cmyk: {
|
||||
(...gray: Gray): CMYK;
|
||||
raw: (...gray: Gray) => CMYK;
|
||||
};
|
||||
lab: {
|
||||
(...gray: Gray): LAB;
|
||||
raw: (...gray: Gray) => LAB;
|
||||
};
|
||||
hex: {
|
||||
(...gray: Gray): HEX;
|
||||
raw: (...gray: Gray) => HEX;
|
||||
};
|
||||
} & route.gray & {
|
||||
[F in keyof route.gray]: {
|
||||
raw: route.gray[F];
|
||||
};
|
||||
};
|
||||
};
|
||||
declare const convert: Convert;
|
||||
export type ColorLike = ColorInstance | string | ArrayLike<number> | number | Record<string, any>;
|
||||
export type ColorJson = {
|
||||
model: string;
|
||||
color: number[];
|
||||
valpha: number;
|
||||
};
|
||||
export type ColorObject = {
|
||||
alpha?: number | undefined;
|
||||
} & Record<string, number>;
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
export interface ColorInstance {
|
||||
toString(): string;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
toJSON(): ColorJson;
|
||||
string(places?: number): string;
|
||||
percentString(places?: number): string;
|
||||
array(): number[];
|
||||
object(): ColorObject;
|
||||
unitArray(): number[];
|
||||
unitObject(): {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
alpha?: number | undefined;
|
||||
};
|
||||
round(places?: number): ColorInstance;
|
||||
alpha(): number;
|
||||
alpha(value: number): ColorInstance;
|
||||
red(): number;
|
||||
red(value: number): ColorInstance;
|
||||
green(): number;
|
||||
green(value: number): ColorInstance;
|
||||
blue(): number;
|
||||
blue(value: number): ColorInstance;
|
||||
hue(): number;
|
||||
hue(value: number): ColorInstance;
|
||||
saturationl(): number;
|
||||
saturationl(value: number): ColorInstance;
|
||||
lightness(): number;
|
||||
lightness(value: number): ColorInstance;
|
||||
saturationv(): number;
|
||||
saturationv(value: number): ColorInstance;
|
||||
value(): number;
|
||||
value(value: number): ColorInstance;
|
||||
chroma(): number;
|
||||
chroma(value: number): ColorInstance;
|
||||
gray(): number;
|
||||
gray(value: number): ColorInstance;
|
||||
white(): number;
|
||||
white(value: number): ColorInstance;
|
||||
wblack(): number;
|
||||
wblack(value: number): ColorInstance;
|
||||
cyan(): number;
|
||||
cyan(value: number): ColorInstance;
|
||||
magenta(): number;
|
||||
magenta(value: number): ColorInstance;
|
||||
yellow(): number;
|
||||
yellow(value: number): ColorInstance;
|
||||
black(): number;
|
||||
black(value: number): ColorInstance;
|
||||
x(): number;
|
||||
x(value: number): ColorInstance;
|
||||
y(): number;
|
||||
y(value: number): ColorInstance;
|
||||
z(): number;
|
||||
z(value: number): ColorInstance;
|
||||
l(): number;
|
||||
l(value: number): ColorInstance;
|
||||
a(): number;
|
||||
a(value: number): ColorInstance;
|
||||
b(): number;
|
||||
b(value: number): ColorInstance;
|
||||
keyword(): string;
|
||||
keyword<V extends string>(value: V): ColorInstance;
|
||||
hex(): string;
|
||||
hex<V extends string>(value: V): ColorInstance;
|
||||
hexa(): string;
|
||||
hexa<V extends string>(value: V): ColorInstance;
|
||||
rgbNumber(): number;
|
||||
luminosity(): number;
|
||||
contrast(color2: ColorInstance): number;
|
||||
level(color2: ColorInstance): "AAA" | "AA" | "";
|
||||
isDark(): boolean;
|
||||
isLight(): boolean;
|
||||
negate(): ColorInstance;
|
||||
lighten(ratio: number): ColorInstance;
|
||||
darken(ratio: number): ColorInstance;
|
||||
saturate(ratio: number): ColorInstance;
|
||||
desaturate(ratio: number): ColorInstance;
|
||||
whiten(ratio: number): ColorInstance;
|
||||
blacken(ratio: number): ColorInstance;
|
||||
grayscale(): ColorInstance;
|
||||
fade(ratio: number): ColorInstance;
|
||||
opaquer(ratio: number): ColorInstance;
|
||||
rotate(degrees: number): ColorInstance;
|
||||
mix(mixinColor: ColorInstance, weight?: number): ColorInstance;
|
||||
rgb(...arguments_: number[]): ColorInstance;
|
||||
hsl(...arguments_: number[]): ColorInstance;
|
||||
hsv(...arguments_: number[]): ColorInstance;
|
||||
hwb(...arguments_: number[]): ColorInstance;
|
||||
cmyk(...arguments_: number[]): ColorInstance;
|
||||
xyz(...arguments_: number[]): ColorInstance;
|
||||
lab(...arguments_: number[]): ColorInstance;
|
||||
lch(...arguments_: number[]): ColorInstance;
|
||||
ansi16(...arguments_: number[]): ColorInstance;
|
||||
ansi256(...arguments_: number[]): ColorInstance;
|
||||
hcg(...arguments_: number[]): ColorInstance;
|
||||
apple(...arguments_: number[]): ColorInstance;
|
||||
}
|
||||
export type ColorConstructor = {
|
||||
(object?: ColorLike, model?: keyof (typeof convert)): ColorInstance;
|
||||
new (object?: ColorLike, model?: keyof (typeof convert)): ColorInstance;
|
||||
rgb(...value: number[]): ColorInstance;
|
||||
rgb(color: ColorLike): ColorInstance;
|
||||
hsl(...value: number[]): ColorInstance;
|
||||
hsl(color: ColorLike): ColorInstance;
|
||||
hsv(...value: number[]): ColorInstance;
|
||||
hsv(color: ColorLike): ColorInstance;
|
||||
hwb(...value: number[]): ColorInstance;
|
||||
hwb(color: ColorLike): ColorInstance;
|
||||
cmyk(...value: number[]): ColorInstance;
|
||||
cmyk(color: ColorLike): ColorInstance;
|
||||
xyz(...value: number[]): ColorInstance;
|
||||
xyz(color: ColorLike): ColorInstance;
|
||||
lab(...value: number[]): ColorInstance;
|
||||
lab(color: ColorLike): ColorInstance;
|
||||
lch(...value: number[]): ColorInstance;
|
||||
lch(color: ColorLike): ColorInstance;
|
||||
ansi16(...value: number[]): ColorInstance;
|
||||
ansi16(color: ColorLike): ColorInstance;
|
||||
ansi256(...value: number[]): ColorInstance;
|
||||
ansi256(color: ColorLike): ColorInstance;
|
||||
hcg(...value: number[]): ColorInstance;
|
||||
hcg(color: ColorLike): ColorInstance;
|
||||
apple(...value: number[]): ColorInstance;
|
||||
apple(color: ColorLike): ColorInstance;
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
declare const Color: ColorConstructor;
|
||||
|
||||
export {
|
||||
Color as default,
|
||||
};
|
||||
|
||||
export {};
|
||||
58
frontend/node_modules/@img/colour/package.json
generated
vendored
Normal file
58
frontend/node_modules/@img/colour/package.json
generated
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"name": "@img/colour",
|
||||
"version": "1.1.0",
|
||||
"description": "The ESM-only 'color' package made compatible for use with CommonJS runtimes",
|
||||
"license": "MIT",
|
||||
"main": "index.cjs",
|
||||
"types": "index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.d.ts",
|
||||
"require": "./index.cjs",
|
||||
"default": "./index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"authors": [
|
||||
"Heather Arthur <fayearthur@gmail.com>",
|
||||
"Josh Junon <josh@junon.me>",
|
||||
"Maxime Thirouin",
|
||||
"Dyma Ywanov <dfcreative@gmail.com>",
|
||||
"LitoMore (https://github.com/LitoMore)"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"color.cjs",
|
||||
"index.d.ts"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/lovell/colour.git"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"keywords": [
|
||||
"color",
|
||||
"colour",
|
||||
"cjs",
|
||||
"commonjs"
|
||||
],
|
||||
"scripts": {
|
||||
"build:cjs": "esbuild node_modules/color/index.js --bundle --platform=node --outfile=color.cjs",
|
||||
"build:dts": "dts-bundle-generator ./dts-src.ts -o index.d.ts --project tsconfig.build.json --external-inlines color --external-inlines color-convert --export-referenced-types=false",
|
||||
"build": "npm run build:cjs && npm run build:dts",
|
||||
"test": "node --test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"color": "5.0.3",
|
||||
"color-convert": "3.1.3",
|
||||
"color-name": "2.1.0",
|
||||
"color-string": "2.1.4",
|
||||
"dts-bundle-generator": "^9.5.1",
|
||||
"esbuild": "^0.27.3"
|
||||
}
|
||||
}
|
||||
191
frontend/node_modules/@img/sharp-win32-x64/LICENSE
generated
vendored
Normal file
191
frontend/node_modules/@img/sharp-win32-x64/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, "control" means (i) the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
"submitted" means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License.
|
||||
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution.
|
||||
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
|
||||
You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
If the Work includes a "NOTICE" text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions.
|
||||
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
|
||||
6. Trademarks.
|
||||
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
|
||||
8. Limitation of Liability.
|
||||
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability.
|
||||
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets "[]" replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same "printed page" as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
59
frontend/node_modules/@img/sharp-win32-x64/README.md
generated
vendored
Normal file
59
frontend/node_modules/@img/sharp-win32-x64/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# `@img/sharp-win32-x64`
|
||||
|
||||
Prebuilt sharp for use with Windows x64.
|
||||
|
||||
## Licensing
|
||||
|
||||
Copyright 2013 Lovell Fuller and others.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
[https://www.apache.org/licenses/LICENSE-2.0](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
This software contains third-party libraries
|
||||
used under the terms of the following licences:
|
||||
|
||||
| Library | Used under the terms of |
|
||||
|---------------|-----------------------------------------------------------------------------------------------------------|
|
||||
| aom | BSD 2-Clause + [Alliance for Open Media Patent License 1.0](https://aomedia.org/license/patent-license/) |
|
||||
| cairo | Mozilla Public License 2.0 |
|
||||
| cgif | MIT Licence |
|
||||
| expat | MIT Licence |
|
||||
| fontconfig | [fontconfig Licence](https://gitlab.freedesktop.org/fontconfig/fontconfig/blob/main/COPYING) (BSD-like) |
|
||||
| freetype | [freetype Licence](https://git.savannah.gnu.org/cgit/freetype/freetype2.git/tree/docs/FTL.TXT) (BSD-like) |
|
||||
| fribidi | LGPLv3 |
|
||||
| glib | LGPLv3 |
|
||||
| harfbuzz | MIT Licence |
|
||||
| highway | Apache-2.0 License, BSD 3-Clause |
|
||||
| lcms | MIT Licence |
|
||||
| libarchive | BSD 2-Clause |
|
||||
| libexif | LGPLv3 |
|
||||
| libffi | MIT Licence |
|
||||
| libheif | LGPLv3 |
|
||||
| libimagequant | [BSD 2-Clause](https://github.com/lovell/libimagequant/blob/main/COPYRIGHT) |
|
||||
| libnsgif | MIT Licence |
|
||||
| libpng | [libpng License](https://github.com/pnggroup/libpng/blob/master/LICENSE) |
|
||||
| librsvg | LGPLv3 |
|
||||
| libspng | [BSD 2-Clause, libpng License](https://github.com/randy408/libspng/blob/master/LICENSE) |
|
||||
| libtiff | [libtiff License](https://gitlab.com/libtiff/libtiff/blob/master/LICENSE.md) (BSD-like) |
|
||||
| libvips | LGPLv3 |
|
||||
| libwebp | New BSD License |
|
||||
| libxml2 | MIT Licence |
|
||||
| mozjpeg | [zlib License, IJG License, BSD-3-Clause](https://github.com/mozilla/mozjpeg/blob/master/LICENSE.md) |
|
||||
| pango | LGPLv3 |
|
||||
| pixman | MIT Licence |
|
||||
| proxy-libintl | LGPLv3 |
|
||||
| zlib-ng | [zlib Licence](https://github.com/zlib-ng/zlib-ng/blob/develop/LICENSE.md) |
|
||||
|
||||
Use of libraries under the terms of the LGPLv3 is via the
|
||||
"any later version" clause of the LGPLv2 or LGPLv2.1.
|
||||
|
||||
Please report any errors or omissions via
|
||||
https://github.com/lovell/sharp-libvips/issues/new
|
||||
BIN
frontend/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll
generated
vendored
Normal file
BIN
frontend/node_modules/@img/sharp-win32-x64/lib/libvips-42.dll
generated
vendored
Normal file
Binary file not shown.
BIN
frontend/node_modules/@img/sharp-win32-x64/lib/libvips-cpp-8.17.3.dll
generated
vendored
Normal file
BIN
frontend/node_modules/@img/sharp-win32-x64/lib/libvips-cpp-8.17.3.dll
generated
vendored
Normal file
Binary file not shown.
BIN
frontend/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node
generated
vendored
Normal file
BIN
frontend/node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node
generated
vendored
Normal file
Binary file not shown.
39
frontend/node_modules/@img/sharp-win32-x64/package.json
generated
vendored
Normal file
39
frontend/node_modules/@img/sharp-win32-x64/package.json
generated
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@img/sharp-win32-x64",
|
||||
"version": "0.34.5",
|
||||
"description": "Prebuilt sharp for use with Windows x64",
|
||||
"author": "Lovell Fuller <npm@lovell.info>",
|
||||
"homepage": "https://sharp.pixelplumbing.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/lovell/sharp.git",
|
||||
"directory": "npm/win32-x64"
|
||||
},
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"preferUnplugged": true,
|
||||
"files": [
|
||||
"lib",
|
||||
"versions.json"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"exports": {
|
||||
"./sharp.node": "./lib/sharp-win32-x64.node",
|
||||
"./package": "./package.json",
|
||||
"./versions": "./versions.json"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
30
frontend/node_modules/@img/sharp-win32-x64/versions.json
generated
vendored
Normal file
30
frontend/node_modules/@img/sharp-win32-x64/versions.json
generated
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"aom": "3.13.1",
|
||||
"archive": "3.8.2",
|
||||
"cairo": "1.18.4",
|
||||
"cgif": "0.5.0",
|
||||
"exif": "0.6.25",
|
||||
"expat": "2.7.3",
|
||||
"ffi": "3.5.2",
|
||||
"fontconfig": "2.17.1",
|
||||
"freetype": "2.14.1",
|
||||
"fribidi": "1.0.16",
|
||||
"glib": "2.86.1",
|
||||
"harfbuzz": "12.1.0",
|
||||
"heif": "1.20.2",
|
||||
"highway": "1.3.0",
|
||||
"imagequant": "2.4.1",
|
||||
"lcms": "2.17",
|
||||
"mozjpeg": "0826579",
|
||||
"pango": "1.57.0",
|
||||
"pixman": "0.46.4",
|
||||
"png": "1.6.50",
|
||||
"proxy-libintl": "0.5",
|
||||
"rsvg": "2.61.2",
|
||||
"spng": "0.7.4",
|
||||
"tiff": "4.7.1",
|
||||
"vips": "8.17.3",
|
||||
"webp": "1.6.0",
|
||||
"xml2": "2.15.1",
|
||||
"zlib-ng": "2.2.5"
|
||||
}
|
||||
19
frontend/node_modules/@jridgewell/gen-mapping/LICENSE
generated
vendored
Normal file
19
frontend/node_modules/@jridgewell/gen-mapping/LICENSE
generated
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
Copyright 2024 Justin Ridgewell <justin@ridgewell.name>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
227
frontend/node_modules/@jridgewell/gen-mapping/README.md
generated
vendored
Normal file
227
frontend/node_modules/@jridgewell/gen-mapping/README.md
generated
vendored
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# @jridgewell/gen-mapping
|
||||
|
||||
> Generate source maps
|
||||
|
||||
`gen-mapping` allows you to generate a source map during transpilation or minification.
|
||||
With a source map, you're able to trace the original location in the source file, either in Chrome's
|
||||
DevTools or using a library like [`@jridgewell/trace-mapping`][trace-mapping].
|
||||
|
||||
You may already be familiar with the [`source-map`][source-map] package's `SourceMapGenerator`. This
|
||||
provides the same `addMapping` and `setSourceContent` API.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm install @jridgewell/gen-mapping
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { GenMapping, addMapping, setSourceContent, toEncodedMap, toDecodedMap } from '@jridgewell/gen-mapping';
|
||||
|
||||
const map = new GenMapping({
|
||||
file: 'output.js',
|
||||
sourceRoot: 'https://example.com/',
|
||||
});
|
||||
|
||||
setSourceContent(map, 'input.js', `function foo() {}`);
|
||||
|
||||
addMapping(map, {
|
||||
// Lines start at line 1, columns at column 0.
|
||||
generated: { line: 1, column: 0 },
|
||||
source: 'input.js',
|
||||
original: { line: 1, column: 0 },
|
||||
});
|
||||
|
||||
addMapping(map, {
|
||||
generated: { line: 1, column: 9 },
|
||||
source: 'input.js',
|
||||
original: { line: 1, column: 9 },
|
||||
name: 'foo',
|
||||
});
|
||||
|
||||
assert.deepEqual(toDecodedMap(map), {
|
||||
version: 3,
|
||||
file: 'output.js',
|
||||
names: ['foo'],
|
||||
sourceRoot: 'https://example.com/',
|
||||
sources: ['input.js'],
|
||||
sourcesContent: ['function foo() {}'],
|
||||
mappings: [
|
||||
[ [0, 0, 0, 0], [9, 0, 0, 9, 0] ]
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(toEncodedMap(map), {
|
||||
version: 3,
|
||||
file: 'output.js',
|
||||
names: ['foo'],
|
||||
sourceRoot: 'https://example.com/',
|
||||
sources: ['input.js'],
|
||||
sourcesContent: ['function foo() {}'],
|
||||
mappings: 'AAAA,SAASA',
|
||||
});
|
||||
```
|
||||
|
||||
### Smaller Sourcemaps
|
||||
|
||||
Not everything needs to be added to a sourcemap, and needless markings can cause signficantly
|
||||
larger file sizes. `gen-mapping` exposes `maybeAddSegment`/`maybeAddMapping` APIs that will
|
||||
intelligently determine if this marking adds useful information. If not, the marking will be
|
||||
skipped.
|
||||
|
||||
```typescript
|
||||
import { maybeAddMapping } from '@jridgewell/gen-mapping';
|
||||
|
||||
const map = new GenMapping();
|
||||
|
||||
// Adding a sourceless marking at the beginning of a line isn't useful.
|
||||
maybeAddMapping(map, {
|
||||
generated: { line: 1, column: 0 },
|
||||
});
|
||||
|
||||
// Adding a new source marking is useful.
|
||||
maybeAddMapping(map, {
|
||||
generated: { line: 1, column: 0 },
|
||||
source: 'input.js',
|
||||
original: { line: 1, column: 0 },
|
||||
});
|
||||
|
||||
// But adding another marking pointing to the exact same original location isn't, even if the
|
||||
// generated column changed.
|
||||
maybeAddMapping(map, {
|
||||
generated: { line: 1, column: 9 },
|
||||
source: 'input.js',
|
||||
original: { line: 1, column: 0 },
|
||||
});
|
||||
|
||||
assert.deepEqual(toEncodedMap(map), {
|
||||
version: 3,
|
||||
names: [],
|
||||
sources: ['input.js'],
|
||||
sourcesContent: [null],
|
||||
mappings: 'AAAA',
|
||||
});
|
||||
```
|
||||
|
||||
## Benchmarks
|
||||
|
||||
```
|
||||
node v18.0.0
|
||||
|
||||
amp.js.map
|
||||
Memory Usage:
|
||||
gen-mapping: addSegment 5852872 bytes
|
||||
gen-mapping: addMapping 7716042 bytes
|
||||
source-map-js 6143250 bytes
|
||||
source-map-0.6.1 6124102 bytes
|
||||
source-map-0.8.0 6121173 bytes
|
||||
Smallest memory usage is gen-mapping: addSegment
|
||||
|
||||
Adding speed:
|
||||
gen-mapping: addSegment x 441 ops/sec ±2.07% (90 runs sampled)
|
||||
gen-mapping: addMapping x 350 ops/sec ±2.40% (86 runs sampled)
|
||||
source-map-js: addMapping x 169 ops/sec ±2.42% (80 runs sampled)
|
||||
source-map-0.6.1: addMapping x 167 ops/sec ±2.56% (80 runs sampled)
|
||||
source-map-0.8.0: addMapping x 168 ops/sec ±2.52% (80 runs sampled)
|
||||
Fastest is gen-mapping: addSegment
|
||||
|
||||
Generate speed:
|
||||
gen-mapping: decoded output x 150,824,370 ops/sec ±0.07% (102 runs sampled)
|
||||
gen-mapping: encoded output x 663 ops/sec ±0.22% (98 runs sampled)
|
||||
source-map-js: encoded output x 197 ops/sec ±0.45% (84 runs sampled)
|
||||
source-map-0.6.1: encoded output x 198 ops/sec ±0.33% (85 runs sampled)
|
||||
source-map-0.8.0: encoded output x 197 ops/sec ±0.06% (93 runs sampled)
|
||||
Fastest is gen-mapping: decoded output
|
||||
|
||||
|
||||
***
|
||||
|
||||
|
||||
babel.min.js.map
|
||||
Memory Usage:
|
||||
gen-mapping: addSegment 37578063 bytes
|
||||
gen-mapping: addMapping 37212897 bytes
|
||||
source-map-js 47638527 bytes
|
||||
source-map-0.6.1 47690503 bytes
|
||||
source-map-0.8.0 47470188 bytes
|
||||
Smallest memory usage is gen-mapping: addMapping
|
||||
|
||||
Adding speed:
|
||||
gen-mapping: addSegment x 31.05 ops/sec ±8.31% (43 runs sampled)
|
||||
gen-mapping: addMapping x 29.83 ops/sec ±7.36% (51 runs sampled)
|
||||
source-map-js: addMapping x 20.73 ops/sec ±6.22% (38 runs sampled)
|
||||
source-map-0.6.1: addMapping x 20.03 ops/sec ±10.51% (38 runs sampled)
|
||||
source-map-0.8.0: addMapping x 19.30 ops/sec ±8.27% (37 runs sampled)
|
||||
Fastest is gen-mapping: addSegment
|
||||
|
||||
Generate speed:
|
||||
gen-mapping: decoded output x 381,379,234 ops/sec ±0.29% (96 runs sampled)
|
||||
gen-mapping: encoded output x 95.15 ops/sec ±2.98% (72 runs sampled)
|
||||
source-map-js: encoded output x 15.20 ops/sec ±7.41% (33 runs sampled)
|
||||
source-map-0.6.1: encoded output x 16.36 ops/sec ±10.46% (31 runs sampled)
|
||||
source-map-0.8.0: encoded output x 16.06 ops/sec ±6.45% (31 runs sampled)
|
||||
Fastest is gen-mapping: decoded output
|
||||
|
||||
|
||||
***
|
||||
|
||||
|
||||
preact.js.map
|
||||
Memory Usage:
|
||||
gen-mapping: addSegment 416247 bytes
|
||||
gen-mapping: addMapping 419824 bytes
|
||||
source-map-js 1024619 bytes
|
||||
source-map-0.6.1 1146004 bytes
|
||||
source-map-0.8.0 1113250 bytes
|
||||
Smallest memory usage is gen-mapping: addSegment
|
||||
|
||||
Adding speed:
|
||||
gen-mapping: addSegment x 13,755 ops/sec ±0.15% (98 runs sampled)
|
||||
gen-mapping: addMapping x 13,013 ops/sec ±0.11% (101 runs sampled)
|
||||
source-map-js: addMapping x 4,564 ops/sec ±0.21% (98 runs sampled)
|
||||
source-map-0.6.1: addMapping x 4,562 ops/sec ±0.11% (99 runs sampled)
|
||||
source-map-0.8.0: addMapping x 4,593 ops/sec ±0.11% (100 runs sampled)
|
||||
Fastest is gen-mapping: addSegment
|
||||
|
||||
Generate speed:
|
||||
gen-mapping: decoded output x 379,864,020 ops/sec ±0.23% (93 runs sampled)
|
||||
gen-mapping: encoded output x 14,368 ops/sec ±4.07% (82 runs sampled)
|
||||
source-map-js: encoded output x 5,261 ops/sec ±0.21% (99 runs sampled)
|
||||
source-map-0.6.1: encoded output x 5,124 ops/sec ±0.58% (99 runs sampled)
|
||||
source-map-0.8.0: encoded output x 5,434 ops/sec ±0.33% (96 runs sampled)
|
||||
Fastest is gen-mapping: decoded output
|
||||
|
||||
|
||||
***
|
||||
|
||||
|
||||
react.js.map
|
||||
Memory Usage:
|
||||
gen-mapping: addSegment 975096 bytes
|
||||
gen-mapping: addMapping 1102981 bytes
|
||||
source-map-js 2918836 bytes
|
||||
source-map-0.6.1 2885435 bytes
|
||||
source-map-0.8.0 2874336 bytes
|
||||
Smallest memory usage is gen-mapping: addSegment
|
||||
|
||||
Adding speed:
|
||||
gen-mapping: addSegment x 4,772 ops/sec ±0.15% (100 runs sampled)
|
||||
gen-mapping: addMapping x 4,456 ops/sec ±0.13% (97 runs sampled)
|
||||
source-map-js: addMapping x 1,618 ops/sec ±0.24% (97 runs sampled)
|
||||
source-map-0.6.1: addMapping x 1,622 ops/sec ±0.12% (99 runs sampled)
|
||||
source-map-0.8.0: addMapping x 1,631 ops/sec ±0.12% (100 runs sampled)
|
||||
Fastest is gen-mapping: addSegment
|
||||
|
||||
Generate speed:
|
||||
gen-mapping: decoded output x 379,107,695 ops/sec ±0.07% (99 runs sampled)
|
||||
gen-mapping: encoded output x 5,421 ops/sec ±1.60% (89 runs sampled)
|
||||
source-map-js: encoded output x 2,113 ops/sec ±1.81% (98 runs sampled)
|
||||
source-map-0.6.1: encoded output x 2,126 ops/sec ±0.10% (100 runs sampled)
|
||||
source-map-0.8.0: encoded output x 2,176 ops/sec ±0.39% (98 runs sampled)
|
||||
Fastest is gen-mapping: decoded output
|
||||
```
|
||||
|
||||
[source-map]: https://www.npmjs.com/package/source-map
|
||||
[trace-mapping]: https://github.com/jridgewell/sourcemaps/tree/main/packages/trace-mapping
|
||||
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue