4220 lines
113 KiB
Go
4220 lines
113 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf16"
|
|
"unicode/utf8"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/gorilla/mux"
|
|
"golang.org/x/crypto/argon2"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
//go:embed web/dist
|
|
var embeddedWeb embed.FS
|
|
|
|
type Server struct {
|
|
db *sql.DB
|
|
orm *ormRepo
|
|
config Config
|
|
storage Storage
|
|
limiter *rateLimiter
|
|
searchContent *searchContentCache
|
|
}
|
|
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
entries map[string]*rateEntry
|
|
}
|
|
|
|
type rateEntry struct {
|
|
Count int
|
|
WindowEnds time.Time
|
|
}
|
|
|
|
type searchContentCache struct {
|
|
mu sync.Mutex
|
|
maxEntries int
|
|
clock uint64
|
|
items map[string]searchContentCacheEntry
|
|
}
|
|
|
|
type searchContentCacheEntry struct {
|
|
text string
|
|
used uint64
|
|
}
|
|
|
|
func newRateLimiter() *rateLimiter {
|
|
return &rateLimiter{entries: make(map[string]*rateEntry)}
|
|
}
|
|
|
|
func newSearchContentCache(maxEntries int) *searchContentCache {
|
|
if maxEntries < 1 {
|
|
maxEntries = 256
|
|
}
|
|
return &searchContentCache{
|
|
maxEntries: maxEntries,
|
|
items: make(map[string]searchContentCacheEntry, maxEntries),
|
|
}
|
|
}
|
|
|
|
func (c *searchContentCache) get(key string) (string, bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
entry, ok := c.items[key]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
c.clock++
|
|
entry.used = c.clock
|
|
c.items[key] = entry
|
|
return entry.text, true
|
|
}
|
|
|
|
func (c *searchContentCache) put(key, text string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
c.clock++
|
|
c.items[key] = searchContentCacheEntry{text: text, used: c.clock}
|
|
if len(c.items) <= c.maxEntries {
|
|
return
|
|
}
|
|
|
|
oldestKey := ""
|
|
oldestUsed := c.clock
|
|
for candidate, entry := range c.items {
|
|
if oldestKey == "" || entry.used < oldestUsed {
|
|
oldestKey = candidate
|
|
oldestUsed = entry.used
|
|
}
|
|
}
|
|
if oldestKey != "" {
|
|
delete(c.items, oldestKey)
|
|
}
|
|
}
|
|
|
|
func (rl *rateLimiter) allow(key string, limit int, now time.Time) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
entry, ok := rl.entries[key]
|
|
if !ok || now.After(entry.WindowEnds) {
|
|
rl.entries[key] = &rateEntry{Count: 1, WindowEnds: now.Add(time.Minute)}
|
|
return true
|
|
}
|
|
|
|
if entry.Count >= limit {
|
|
return false
|
|
}
|
|
|
|
entry.Count++
|
|
return true
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
|
r.status = code
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
type key int
|
|
|
|
const (
|
|
userIDKey key = 1
|
|
)
|
|
|
|
type AccessClaims struct {
|
|
UserID int64 `json:"uid"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type AdminClaims struct {
|
|
Login string `json:"login"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type User struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Theme string `json:"theme"`
|
|
ColorMode string `json:"colorMode"`
|
|
Archive string `json:"archiveFormat"`
|
|
}
|
|
|
|
type FileEntry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"isDir"`
|
|
Size int64 `json:"size"`
|
|
ModTime time.Time `json:"modTime"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
type FileMeta struct {
|
|
Name string
|
|
Size int64
|
|
ModTime time.Time
|
|
IsDir bool
|
|
}
|
|
|
|
type ReadSeekCloser interface {
|
|
io.ReadSeeker
|
|
io.Closer
|
|
}
|
|
|
|
type Storage interface {
|
|
List(userID int64, rel string) ([]FileEntry, error)
|
|
Mkdir(userID int64, rel string) error
|
|
Save(userID int64, rel string, src multipart.File) error
|
|
SaveBytes(userID int64, rel string, data []byte) error
|
|
Delete(userID int64, rel string) error
|
|
Stat(userID int64, rel string) (FileMeta, error)
|
|
OpenReadSeeker(userID int64, rel string) (ReadSeekCloser, error)
|
|
}
|
|
|
|
func main() {
|
|
if maybeRunHashCommand() {
|
|
return
|
|
}
|
|
|
|
cfg := loadConfig()
|
|
db, err := openDB(cfg.DBPath)
|
|
if err != nil {
|
|
log.Fatalf("db open failed: %v", err)
|
|
}
|
|
|
|
if err := migrate(db); err != nil {
|
|
log.Fatalf("migrate failed: %v", err)
|
|
}
|
|
|
|
storage, err := buildStorage(cfg)
|
|
if err != nil {
|
|
log.Fatalf("storage init failed: %v", err)
|
|
}
|
|
|
|
orm, err := newORMRepo(cfg.DBPath)
|
|
if err != nil {
|
|
log.Fatalf("orm init failed: %v", err)
|
|
}
|
|
|
|
if err := startProtocolServers(cfg, db); err != nil {
|
|
log.Fatalf("protocol init failed: %v", err)
|
|
}
|
|
|
|
s := &Server{
|
|
db: db,
|
|
orm: orm,
|
|
config: cfg,
|
|
storage: storage,
|
|
limiter: newRateLimiter(),
|
|
searchContent: newSearchContentCache(256),
|
|
}
|
|
r := mux.NewRouter()
|
|
r.Use(s.recoverMiddleware)
|
|
r.Use(s.securityHeadersMiddleware)
|
|
r.Use(s.hostGuardMiddleware)
|
|
r.Use(s.corsMiddleware)
|
|
r.Use(s.bodyLimitMiddleware)
|
|
r.Use(s.rateLimitMiddleware)
|
|
r.Use(s.requestLogMiddleware)
|
|
|
|
r.HandleFunc("/api/health", s.handleHealth).Methods(http.MethodGet)
|
|
|
|
r.HandleFunc("/api/auth/register", s.handleRegisterDisabled).Methods(http.MethodPost)
|
|
r.HandleFunc("/api/auth/login", s.handleLogin).Methods(http.MethodPost)
|
|
r.HandleFunc("/api/auth/google/start", s.handleGoogleAuthStart).Methods(http.MethodGet)
|
|
r.HandleFunc("/api/auth/google/callback", s.handleGoogleAuthCallback).Methods(http.MethodGet)
|
|
r.HandleFunc("/api/auth/refresh", s.handleRefresh).Methods(http.MethodPost)
|
|
r.HandleFunc("/api/auth/logout", s.handleLogout).Methods(http.MethodPost)
|
|
|
|
r.HandleFunc("/api/admin/login", s.handleAdminLogin).Methods(http.MethodPost)
|
|
r.HandleFunc("/api/admin/logout", s.handleAdminLogout).Methods(http.MethodPost)
|
|
|
|
r.HandleFunc("/share/{token}", s.handleSharedPage).Methods(http.MethodGet, http.MethodHead)
|
|
r.HandleFunc("/api/share/{token}/preview", s.handleSharedPreview).Methods(http.MethodGet, http.MethodHead)
|
|
r.HandleFunc("/api/share/{token}", s.handleSharedDownload).Methods(http.MethodGet, http.MethodHead)
|
|
|
|
protected := r.PathPrefix("/api").Subrouter()
|
|
protected.Use(s.authMiddleware)
|
|
protected.HandleFunc("/auth/me", s.handleMe).Methods(http.MethodGet)
|
|
protected.HandleFunc("/user/preferences", s.handleSetPreferences).Methods(http.MethodPost)
|
|
protected.HandleFunc("/user/google/link/start", s.handleGoogleLinkStart).Methods(http.MethodGet)
|
|
protected.HandleFunc("/user/protocols", s.handleUserProtocols).Methods(http.MethodGet)
|
|
protected.HandleFunc("/files", s.handleListFiles).Methods(http.MethodGet)
|
|
protected.HandleFunc("/files/search", s.handleSearchFiles).Methods(http.MethodGet)
|
|
protected.HandleFunc("/files/upload", s.handleUpload).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files/download", s.handleDownload).Methods(http.MethodGet, http.MethodHead)
|
|
protected.HandleFunc("/files/download-batch", s.handleBatchDownload).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files/move-batch", s.handleBatchMove).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files/preview", s.handlePreview).Methods(http.MethodGet, http.MethodHead)
|
|
protected.HandleFunc("/files/thumbnail", s.handleThumbnail).Methods(http.MethodGet, http.MethodHead)
|
|
protected.HandleFunc("/files/content-preview", s.handleContentPreview).Methods(http.MethodGet)
|
|
protected.HandleFunc("/files/text", s.handleReadTextFile).Methods(http.MethodGet)
|
|
protected.HandleFunc("/files/text", s.handleWriteTextFile).Methods(http.MethodPut)
|
|
protected.HandleFunc("/files/rename", s.handleRename).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files", s.handleDelete).Methods(http.MethodDelete)
|
|
protected.HandleFunc("/files/folder", s.handleCreateFolder).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files/share", s.handleCreateShareLink).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files/tags", s.handleListFileTags).Methods(http.MethodGet)
|
|
protected.HandleFunc("/files/tags", s.handleAddFileTag).Methods(http.MethodPost)
|
|
protected.HandleFunc("/files/tags", s.handleDeleteFileTag).Methods(http.MethodDelete)
|
|
|
|
admin := r.PathPrefix("/api/admin").Subrouter()
|
|
admin.Use(s.adminMiddleware)
|
|
admin.HandleFunc("/me", s.handleAdminMe).Methods(http.MethodGet)
|
|
admin.HandleFunc("/users", s.handleAdminUsersList).Methods(http.MethodGet)
|
|
admin.HandleFunc("/users", s.handleAdminUserCreate).Methods(http.MethodPost)
|
|
admin.HandleFunc("/users/{id}", s.handleAdminUserDelete).Methods(http.MethodDelete)
|
|
|
|
r.PathPrefix("/").Handler(s.staticHandler())
|
|
|
|
server := &http.Server{
|
|
Addr: cfg.Addr,
|
|
Handler: r,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
log.Printf("listening on %s", cfg.Addr)
|
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("server failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func openDB(path string) (*sql.DB, error) {
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
return nil, err
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func migrate(db *sql.DB) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
theme TEXT NOT NULL DEFAULT 'dracula',
|
|
color_mode TEXT NOT NULL DEFAULT 'auto',
|
|
archive_format TEXT NOT NULL DEFAULT 'zip',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
token_hash TEXT NOT NULL,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
revoked_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
user_agent TEXT,
|
|
ip TEXT,
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
);`,
|
|
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);`,
|
|
`CREATE TABLE IF NOT EXISTS share_links (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
rel_path TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
max_downloads INTEGER,
|
|
download_count INTEGER NOT NULL DEFAULT 0,
|
|
revoked_at TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
);`,
|
|
`CREATE INDEX IF NOT EXISTS idx_share_links_hash ON share_links(token_hash);`,
|
|
`CREATE TABLE IF NOT EXISTS file_tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
rel_path TEXT NOT NULL,
|
|
tag TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, rel_path, tag),
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
);`,
|
|
`CREATE INDEX IF NOT EXISTS idx_file_tags_user_path ON file_tags(user_id, rel_path);`,
|
|
`CREATE INDEX IF NOT EXISTS idx_file_tags_user_tag ON file_tags(user_id, tag);`,
|
|
`CREATE TABLE IF NOT EXISTS search_content_cache (
|
|
user_id INTEGER NOT NULL,
|
|
rel_path TEXT NOT NULL,
|
|
extractor TEXT NOT NULL,
|
|
file_size INTEGER NOT NULL,
|
|
mod_time_ns INTEGER NOT NULL,
|
|
content TEXT NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY(user_id, rel_path, extractor),
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
);`,
|
|
`CREATE TABLE IF NOT EXISTS preview_thumbnail_cache (
|
|
user_id INTEGER NOT NULL,
|
|
rel_path TEXT NOT NULL,
|
|
renderer TEXT NOT NULL,
|
|
file_size INTEGER NOT NULL,
|
|
mod_time_ns INTEGER NOT NULL,
|
|
content_type TEXT NOT NULL,
|
|
image BLOB NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY(user_id, rel_path, renderer),
|
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
|
);`,
|
|
}
|
|
|
|
for _, stmt := range stmts {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := db.Exec(`ALTER TABLE users ADD COLUMN color_mode TEXT NOT NULL DEFAULT 'auto'`); err != nil {
|
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := db.Exec(`ALTER TABLE users ADD COLUMN archive_format TEXT NOT NULL DEFAULT 'zip'`); err != nil {
|
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := db.Exec(`ALTER TABLE users ADD COLUMN google_sub TEXT`); err != nil {
|
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_google_sub ON users(google_sub) WHERE google_sub IS NOT NULL`); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func buildStorage(cfg Config) (Storage, error) {
|
|
root := cfg.StorageRoot
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
return &LocalStorage{root: root}, nil
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) staticHandler() http.Handler {
|
|
webRoot, err := fs.Sub(embeddedWeb, "web/dist")
|
|
if err != nil {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "web assets unavailable", http.StatusServiceUnavailable)
|
|
})
|
|
}
|
|
|
|
fileServer := http.FileServer(http.FS(webRoot))
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
requestPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
|
if requestPath == "." || requestPath == "" {
|
|
requestPath = "index.html"
|
|
}
|
|
|
|
if _, statErr := fs.Stat(webRoot, requestPath); statErr == nil {
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
index, readErr := fs.ReadFile(webRoot, "index.html")
|
|
if readErr != nil {
|
|
http.Error(w, "web app is not bundled", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(index)
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleRegisterDisabled(w http.ResponseWriter, _ *http.Request) {
|
|
writeErr(w, http.StatusForbidden, "public registration is disabled; ask an administrator")
|
|
}
|
|
|
|
type authInput struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var in authInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
user, hash, err := s.findUserWithHash(in.Username)
|
|
if err != nil {
|
|
log.Printf("auth.login.failed username=%q ip=%q reason=%q", in.Username, clientIP(r), "user_not_found")
|
|
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
if !verifyPasswordHash(hash, in.Password) {
|
|
log.Printf("auth.login.failed username=%q ip=%q reason=%q", in.Username, clientIP(r), "invalid_password")
|
|
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
if err := s.issueUserSession(w, r, user.ID); err != nil {
|
|
log.Printf("auth.login.failed username=%q user_id=%d ip=%q reason=%q", user.Username, user.ID, clientIP(r), "session_issue_failed")
|
|
writeErr(w, http.StatusInternalServerError, "failed to create session")
|
|
return
|
|
}
|
|
|
|
log.Printf("auth.login.success user_id=%d username=%q ip=%q", user.ID, user.Username, clientIP(r))
|
|
|
|
writeJSON(w, http.StatusOK, user)
|
|
}
|
|
|
|
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
|
rt, err := r.Cookie("refresh_token")
|
|
if err != nil || rt.Value == "" {
|
|
writeErr(w, http.StatusUnauthorized, "missing refresh token")
|
|
return
|
|
}
|
|
|
|
uid, err := s.consumeRefreshToken(rt.Value)
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "invalid refresh token")
|
|
return
|
|
}
|
|
|
|
if err := s.issueUserSession(w, r, uid); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to refresh session")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "refreshed"})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if cookie, err := r.Cookie("access_token"); err == nil && cookie.Value != "" {
|
|
claims := &AccessClaims{}
|
|
if tkn, parseErr := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
|
|
return []byte(s.config.JWTSecret), nil
|
|
}); parseErr == nil && tkn.Valid {
|
|
log.Printf("auth.logout user_id=%d ip=%q", claims.UserID, clientIP(r))
|
|
}
|
|
}
|
|
|
|
rt, _ := r.Cookie("refresh_token")
|
|
if rt != nil && rt.Value != "" {
|
|
_, _ = s.db.Exec(`UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, hashToken(rt.Value))
|
|
}
|
|
|
|
clearCookie(w, "access_token", s.config.CookieSecure)
|
|
clearCookie(w, "refresh_token", s.config.CookieSecure)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
|
}
|
|
|
|
type adminLoginInput struct {
|
|
Login string `json:"login"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
|
|
var in adminLoginInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
if subtleConstantTimeEq(strings.TrimSpace(in.Login), s.config.AdminLogin) == 0 || !verifyAdminPasswordHash(s.config.AdminPasswordHash, in.Password) {
|
|
log.Printf("auth.admin_login.failed login=%q ip=%q", strings.TrimSpace(in.Login), clientIP(r))
|
|
writeErr(w, http.StatusUnauthorized, "invalid admin credentials")
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
claims := AdminClaims{
|
|
Login: s.config.AdminLogin,
|
|
Role: "admin",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.AdminSessionTTL)),
|
|
Subject: s.config.AdminLogin,
|
|
},
|
|
}
|
|
|
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.config.JWTSecret))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to issue admin session")
|
|
return
|
|
}
|
|
|
|
setCookie(w, "admin_token", token, int(s.config.AdminSessionTTL.Seconds()), s.config.CookieSecure)
|
|
log.Printf("auth.admin_login.success login=%q ip=%q", s.config.AdminLogin, clientIP(r))
|
|
writeJSON(w, http.StatusOK, map[string]string{"login": s.config.AdminLogin})
|
|
}
|
|
|
|
func (s *Server) handleAdminLogout(w http.ResponseWriter, _ *http.Request) {
|
|
log.Printf("auth.admin_logout")
|
|
clearCookie(w, "admin_token", s.config.CookieSecure)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
|
}
|
|
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
user, err := s.findUser(uid)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, user)
|
|
}
|
|
|
|
type userPrefInput struct {
|
|
Theme string `json:"theme"`
|
|
ColorMode string `json:"colorMode"`
|
|
ArchiveFmt string `json:"archiveFormat"`
|
|
}
|
|
|
|
func (s *Server) handleSetPreferences(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in userPrefInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
in.Theme = normalizeTheme(in.Theme)
|
|
in.ColorMode = normalizeColorMode(in.ColorMode)
|
|
in.ArchiveFmt = normalizeArchiveFormat(in.ArchiveFmt)
|
|
if in.ArchiveFmt == "" {
|
|
in.ArchiveFmt = "zip"
|
|
}
|
|
|
|
if _, err := s.db.Exec(`UPDATE users SET theme = ?, color_mode = ?, archive_format = ? WHERE id = ?`, in.Theme, in.ColorMode, in.ArchiveFmt, uid); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to update preferences")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"theme": in.Theme, "colorMode": in.ColorMode, "archiveFormat": in.ArchiveFmt})
|
|
}
|
|
|
|
func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := r.URL.Query().Get("path")
|
|
log.Printf("file.list user_id=%d path=%q", uid, normalizePath(rel))
|
|
entries, err := s.storage.List(uid, rel)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if len(entries) > 0 {
|
|
paths := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
paths = append(paths, normalizePath(e.Path))
|
|
}
|
|
tagsByPath, tagErr := s.fileTagsForPaths(uid, paths)
|
|
if tagErr == nil {
|
|
for i := range entries {
|
|
entries[i].Tags = tagsByPath[normalizePath(entries[i].Path)]
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": normalizePath(rel), "entries": entries})
|
|
}
|
|
|
|
func (s *Server) handleSearchFiles(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
limit := 200
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if limit > 400 {
|
|
limit = 400
|
|
}
|
|
log.Printf("file.search user_id=%d query=%q limit=%d", uid, query, limit)
|
|
|
|
entries, err := s.searchFiles(uid, query, limit)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if len(entries) > 0 {
|
|
paths := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
paths = append(paths, normalizePath(entry.Path))
|
|
}
|
|
tagsByPath, tagErr := s.fileTagsForPaths(uid, paths)
|
|
if tagErr == nil {
|
|
for i := range entries {
|
|
entries[i].Tags = tagsByPath[normalizePath(entries[i].Path)]
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"query": query, "entries": entries})
|
|
}
|
|
|
|
type scoredFileEntry struct {
|
|
Entry FileEntry
|
|
Score int
|
|
}
|
|
|
|
type limitedBuffer struct {
|
|
limit int
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
|
if remaining := b.limit - b.buf.Len(); remaining > 0 {
|
|
if len(p) > remaining {
|
|
p = p[:remaining]
|
|
}
|
|
_, _ = b.buf.Write(p)
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (b *limitedBuffer) String() string {
|
|
return b.buf.String()
|
|
}
|
|
|
|
const (
|
|
searchContentCacheEntries = 256
|
|
maxSearchExtractBytes = 2 << 20
|
|
maxSearchPlainTextBytes = 2 << 20
|
|
maxSearchDocBytes = 64 << 20
|
|
maxSearchOCRBytes = 20 << 20
|
|
searchExtractTimeout = 12 * time.Second
|
|
previewRenderTimeout = 20 * time.Second
|
|
previewThumbnailScale = 960
|
|
maxPreviewThumbnailBytes = 2 << 20
|
|
)
|
|
|
|
var searchableTextExtensions = map[string]struct{}{
|
|
".c": {}, ".cc": {}, ".cfg": {}, ".conf": {}, ".cpp": {}, ".cs": {}, ".css": {}, ".csv": {}, ".env": {},
|
|
".go": {}, ".h": {}, ".hpp": {}, ".html": {}, ".ini": {}, ".java": {}, ".js": {}, ".json": {}, ".jsx": {},
|
|
".kt": {}, ".less": {}, ".log": {}, ".lua": {}, ".markdown": {}, ".md": {}, ".php": {}, ".pl": {}, ".properties": {},
|
|
".py": {}, ".rb": {}, ".rs": {}, ".scss": {}, ".sh": {}, ".sql": {}, ".svg": {}, ".svelte": {}, ".tex": {},
|
|
".toml": {}, ".ts": {}, ".tsx": {}, ".txt": {}, ".vtt": {}, ".xml": {}, ".yaml": {}, ".yml": {},
|
|
}
|
|
|
|
var searchableZipDocumentExtensions = map[string]struct{}{
|
|
".docm": {}, ".docx": {}, ".dotm": {}, ".dotx": {}, ".odp": {}, ".ods": {}, ".odt": {},
|
|
".potm": {}, ".potx": {}, ".ppsm": {}, ".ppsx": {}, ".pptm": {}, ".pptx": {}, ".xlsm": {}, ".xlsx": {}, ".xltm": {}, ".xltx": {},
|
|
}
|
|
|
|
var searchableLibreOfficeExtensions = map[string]struct{}{
|
|
".doc": {}, ".docm": {}, ".docx": {}, ".dotm": {}, ".dotx": {}, ".epub": {}, ".fodp": {}, ".fods": {}, ".fodt": {},
|
|
".odg": {}, ".odp": {}, ".ods": {}, ".odt": {}, ".pages": {}, ".potm": {}, ".potx": {}, ".pps": {}, ".ppsm": {},
|
|
".ppsx": {}, ".ppt": {}, ".pptm": {}, ".pptx": {}, ".rtf": {}, ".sxw": {}, ".sxc": {}, ".sxi": {}, ".wpd": {},
|
|
".xls": {}, ".xlsm": {}, ".xlsx": {}, ".xltm": {}, ".xltx": {},
|
|
}
|
|
|
|
var searchableImageExtensions = map[string]struct{}{
|
|
".bmp": {}, ".gif": {}, ".jpeg": {}, ".jpg": {}, ".png": {}, ".tif": {}, ".tiff": {}, ".webp": {},
|
|
}
|
|
|
|
func (s *Server) searchFiles(uid int64, query string, limit int) ([]FileEntry, error) {
|
|
q := strings.ToLower(strings.TrimSpace(query))
|
|
contentQuery := normalizeSearchText(query)
|
|
if q == "" {
|
|
return []FileEntry{}, nil
|
|
}
|
|
if limit < 1 {
|
|
limit = 1
|
|
}
|
|
|
|
stack := []string{"/"}
|
|
matchesByPath := make(map[string]scoredFileEntry)
|
|
|
|
for len(stack) > 0 {
|
|
dir := stack[len(stack)-1]
|
|
stack = stack[:len(stack)-1]
|
|
|
|
entries, err := s.storage.List(uid, dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir {
|
|
stack = append(stack, entry.Path)
|
|
}
|
|
score, ok := fuzzyEntryScore(q, entry.Name, entry.Path)
|
|
if !entry.IsDir {
|
|
if contentScore, contentOK := s.fileContentSearchScore(uid, entry, contentQuery); contentOK && (!ok || contentScore > score) {
|
|
score = contentScore
|
|
ok = true
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
key := normalizePath(entry.Path)
|
|
if existing, found := matchesByPath[key]; !found || score > existing.Score {
|
|
matchesByPath[key] = scoredFileEntry{Entry: entry, Score: score}
|
|
}
|
|
}
|
|
}
|
|
|
|
matches := make([]scoredFileEntry, 0, len(matchesByPath))
|
|
for _, match := range matchesByPath {
|
|
matches = append(matches, match)
|
|
}
|
|
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
if matches[i].Score != matches[j].Score {
|
|
return matches[i].Score > matches[j].Score
|
|
}
|
|
if matches[i].Entry.IsDir != matches[j].Entry.IsDir {
|
|
return matches[i].Entry.IsDir
|
|
}
|
|
if matches[i].Entry.Name != matches[j].Entry.Name {
|
|
return matches[i].Entry.Name < matches[j].Entry.Name
|
|
}
|
|
return matches[i].Entry.Path < matches[j].Entry.Path
|
|
})
|
|
|
|
if len(matches) > limit {
|
|
matches = matches[:limit]
|
|
}
|
|
out := make([]FileEntry, 0, len(matches))
|
|
for _, match := range matches {
|
|
out = append(out, match.Entry)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func fuzzyEntryScore(query, name, fullPath string) (int, bool) {
|
|
best := -1
|
|
if score, ok := fuzzyCandidateScore(query, strings.ToLower(strings.TrimSpace(name))); ok {
|
|
best = score + 240
|
|
}
|
|
if score, ok := fuzzyCandidateScore(query, strings.ToLower(strings.TrimSpace(fullPath))); ok {
|
|
score += 80
|
|
if score > best {
|
|
best = score
|
|
}
|
|
}
|
|
return best, best >= 0
|
|
}
|
|
|
|
func fuzzyCandidateScore(query, candidate string) (int, bool) {
|
|
if query == "" || candidate == "" {
|
|
return 0, false
|
|
}
|
|
if idx := strings.Index(candidate, query); idx >= 0 {
|
|
return 1600 - idx*10 - (len(candidate) - len(query)), true
|
|
}
|
|
|
|
queryRunes := []rune(query)
|
|
candidateRunes := []rune(candidate)
|
|
qi := 0
|
|
score := 0
|
|
firstIdx := -1
|
|
prevMatch := -2
|
|
gaps := 0
|
|
|
|
for i, ch := range candidateRunes {
|
|
if qi >= len(queryRunes) {
|
|
break
|
|
}
|
|
if ch != queryRunes[qi] {
|
|
if firstIdx >= 0 {
|
|
gaps++
|
|
}
|
|
continue
|
|
}
|
|
if firstIdx < 0 {
|
|
firstIdx = i
|
|
score += 120
|
|
}
|
|
score += 85
|
|
if i == 0 || candidateRunes[i-1] == '/' || candidateRunes[i-1] == '-' || candidateRunes[i-1] == '_' || candidateRunes[i-1] == '.' || candidateRunes[i-1] == ' ' {
|
|
score += 45
|
|
}
|
|
if prevMatch == i-1 {
|
|
score += 60
|
|
}
|
|
prevMatch = i
|
|
qi++
|
|
}
|
|
if qi != len(queryRunes) {
|
|
return 0, false
|
|
}
|
|
|
|
score += 700 - gaps*12
|
|
if firstIdx > 0 {
|
|
score -= firstIdx * 6
|
|
}
|
|
overhang := len(candidateRunes) - len(queryRunes)
|
|
if overhang > 0 {
|
|
score -= overhang
|
|
}
|
|
return score, true
|
|
}
|
|
|
|
func (s *Server) fileContentSearchScore(uid int64, entry FileEntry, query string) (int, bool) {
|
|
if query == "" || entry.IsDir {
|
|
return 0, false
|
|
}
|
|
text, ok := s.searchableFileContent(uid, entry)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return contentMatchScore(query, text)
|
|
}
|
|
|
|
func (s *Server) searchableFileContent(uid int64, entry FileEntry) (string, bool) {
|
|
if entry.IsDir {
|
|
return "", false
|
|
}
|
|
if s.searchContent == nil {
|
|
s.searchContent = newSearchContentCache(searchContentCacheEntries)
|
|
}
|
|
|
|
extractor := cacheableSearchExtractor(entry)
|
|
cacheKey := fmt.Sprintf("%d|%s|%s|%d|%d", uid, normalizePath(entry.Path), extractor, entry.Size, entry.ModTime.UTC().UnixNano())
|
|
if text, ok := s.searchContent.get(cacheKey); ok {
|
|
return text, true
|
|
}
|
|
if extractor != "" {
|
|
if text, ok := s.loadPersistedSearchContent(uid, entry.Path, extractor, entry.Size, entry.ModTime); ok {
|
|
s.searchContent.put(cacheKey, text)
|
|
return text, true
|
|
}
|
|
}
|
|
|
|
text, err := s.extractSearchableText(uid, entry)
|
|
if err != nil {
|
|
log.Printf("file.search.extract path=%q error=%v", normalizePath(entry.Path), err)
|
|
text = ""
|
|
}
|
|
text = normalizeSearchText(text)
|
|
s.searchContent.put(cacheKey, text)
|
|
if extractor != "" && err == nil {
|
|
s.storePersistedSearchContent(uid, entry.Path, extractor, entry.Size, entry.ModTime, text)
|
|
}
|
|
return text, true
|
|
}
|
|
|
|
func (s *Server) extractSearchableText(uid int64, entry FileEntry) (string, error) {
|
|
if entry.IsDir || entry.Size == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
fullPath, err := s.localStoragePath(uid, entry.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
|
switch {
|
|
case isSearchableZipDocumentExtension(ext):
|
|
text, err := extractZipDocumentText(fullPath, ext)
|
|
if strings.TrimSpace(text) != "" || err == nil {
|
|
return text, err
|
|
}
|
|
if !isSearchableLibreOfficeExtension(ext) {
|
|
return "", err
|
|
}
|
|
return extractLibreOfficeText(fullPath)
|
|
case ext == ".pdf":
|
|
if entry.Size > maxSearchDocBytes {
|
|
return "", nil
|
|
}
|
|
return extractPDFText(fullPath)
|
|
case isSearchableImageExtension(ext):
|
|
if entry.Size > maxSearchOCRBytes {
|
|
return "", nil
|
|
}
|
|
return extractImageOCRText(fullPath, s.ocrLangs())
|
|
case isSearchableLibreOfficeExtension(ext):
|
|
if entry.Size > maxSearchDocBytes {
|
|
return "", nil
|
|
}
|
|
return extractLibreOfficeText(fullPath)
|
|
default:
|
|
forceText := isSearchableTextExtension(ext)
|
|
if !forceText && entry.Size > maxSearchPlainTextBytes {
|
|
return "", nil
|
|
}
|
|
text, ok, err := extractPlainTextFile(fullPath, forceText)
|
|
if err != nil || ok {
|
|
return text, err
|
|
}
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func (s *Server) extractPreviewText(uid int64, entry FileEntry) (string, error) {
|
|
if entry.IsDir || entry.Size == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
fullPath, err := s.localStoragePath(uid, entry.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
|
switch {
|
|
case ext == ".pdf":
|
|
if entry.Size > maxSearchDocBytes {
|
|
return "", nil
|
|
}
|
|
return extractPDFText(fullPath)
|
|
case isSearchableZipDocumentExtension(ext), isSearchableLibreOfficeExtension(ext):
|
|
if entry.Size > maxSearchDocBytes {
|
|
return "", nil
|
|
}
|
|
text, err := extractLibreOfficeText(fullPath)
|
|
if strings.TrimSpace(text) != "" || err == nil {
|
|
return text, err
|
|
}
|
|
if isSearchableZipDocumentExtension(ext) {
|
|
return extractZipDocumentText(fullPath, ext)
|
|
}
|
|
return "", err
|
|
default:
|
|
forceText := isSearchableTextExtension(ext)
|
|
if !forceText && entry.Size > maxSearchPlainTextBytes {
|
|
return "", nil
|
|
}
|
|
text, ok, err := extractPlainTextFile(fullPath, forceText)
|
|
if err != nil || ok {
|
|
return text, err
|
|
}
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
func (s *Server) extractPreviewThumbnail(uid int64, entry FileEntry) ([]byte, error) {
|
|
if entry.IsDir || entry.Size == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
renderer := cacheableThumbnailRenderer(entry)
|
|
if data, ok := s.loadPersistedPreviewThumbnail(uid, entry.Path, renderer, entry.Size, entry.ModTime); ok {
|
|
return data, nil
|
|
}
|
|
|
|
fullPath, err := s.localStoragePath(uid, entry.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
|
var data []byte
|
|
switch {
|
|
case ext == ".pdf":
|
|
if entry.Size > maxSearchDocBytes {
|
|
return nil, nil
|
|
}
|
|
if rendered, err := renderPDFThumbnail(fullPath); err == nil && len(rendered) > 0 {
|
|
data = rendered
|
|
break
|
|
}
|
|
text, err := extractPDFText(fullPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err = renderTextThumbnail(text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
case isSearchableZipDocumentExtension(ext), isSearchableLibreOfficeExtension(ext):
|
|
if entry.Size > maxSearchDocBytes {
|
|
return nil, nil
|
|
}
|
|
if rendered, err := renderLibreOfficeThumbnail(fullPath); err == nil && len(rendered) > 0 {
|
|
data = rendered
|
|
break
|
|
}
|
|
text, err := s.extractPreviewText(uid, entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err = renderTextThumbnail(text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
default:
|
|
return nil, nil
|
|
}
|
|
|
|
if len(data) > 0 {
|
|
s.storePersistedPreviewThumbnail(uid, entry.Path, renderer, entry.Size, entry.ModTime, "image/png", data)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (s *Server) localStoragePath(uid int64, rel string) (string, error) {
|
|
local, ok := s.storage.(*LocalStorage)
|
|
if !ok {
|
|
return "", fmt.Errorf("content search is only available for local storage")
|
|
}
|
|
return local.fullPath(uid, rel)
|
|
}
|
|
|
|
func cacheableSearchExtractor(entry FileEntry) string {
|
|
if entry.IsDir {
|
|
return ""
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
|
switch {
|
|
case isSearchableImageExtension(ext):
|
|
return "ocr"
|
|
case ext == ".pdf":
|
|
return "pdf"
|
|
case isSearchableZipDocumentExtension(ext), isSearchableLibreOfficeExtension(ext):
|
|
return "document"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func cacheableThumbnailRenderer(entry FileEntry) string {
|
|
if entry.IsDir {
|
|
return ""
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(entry.Name))
|
|
switch {
|
|
case ext == ".pdf":
|
|
return "pdf:v1"
|
|
case isSearchableZipDocumentExtension(ext), isSearchableLibreOfficeExtension(ext):
|
|
return "document:v1"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (s *Server) loadPersistedSearchContent(uid int64, relPath, extractor string, size int64, modTime time.Time) (string, bool) {
|
|
if extractor == "" {
|
|
return "", false
|
|
}
|
|
|
|
norm := normalizePath(relPath)
|
|
var cachedSize int64
|
|
var cachedModTime int64
|
|
var content string
|
|
err := s.db.QueryRow(
|
|
`SELECT file_size, mod_time_ns, content FROM search_content_cache WHERE user_id = ? AND rel_path = ? AND extractor = ?`,
|
|
uid, norm, extractor,
|
|
).Scan(&cachedSize, &cachedModTime, &content)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
if cachedSize != size || cachedModTime != modTime.UTC().UnixNano() {
|
|
_, _ = s.db.Exec(`DELETE FROM search_content_cache WHERE user_id = ? AND rel_path = ? AND extractor = ?`, uid, norm, extractor)
|
|
return "", false
|
|
}
|
|
return content, true
|
|
}
|
|
|
|
func (s *Server) storePersistedSearchContent(uid int64, relPath, extractor string, size int64, modTime time.Time, content string) {
|
|
if extractor == "" {
|
|
return
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO search_content_cache(user_id, rel_path, extractor, file_size, mod_time_ns, content, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(user_id, rel_path, extractor) DO UPDATE SET
|
|
file_size = excluded.file_size,
|
|
mod_time_ns = excluded.mod_time_ns,
|
|
content = excluded.content,
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
uid,
|
|
normalizePath(relPath),
|
|
extractor,
|
|
size,
|
|
modTime.UTC().UnixNano(),
|
|
content,
|
|
)
|
|
if err != nil {
|
|
log.Printf("file.search.cache.store path=%q extractor=%s error=%v", normalizePath(relPath), extractor, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) purgePersistedSearchContent(uid int64, relPath string) {
|
|
norm := normalizePath(relPath)
|
|
_, _ = s.db.Exec(`DELETE FROM search_content_cache WHERE user_id = ? AND (rel_path = ? OR rel_path LIKE ?)`, uid, norm, strings.TrimSuffix(norm, "/")+"/%")
|
|
}
|
|
|
|
func (s *Server) purgePersistedSearchContentForUser(uid int64) {
|
|
_, _ = s.db.Exec(`DELETE FROM search_content_cache WHERE user_id = ?`, uid)
|
|
}
|
|
|
|
func (s *Server) loadPersistedPreviewThumbnail(uid int64, relPath, renderer string, size int64, modTime time.Time) ([]byte, bool) {
|
|
if renderer == "" {
|
|
return nil, false
|
|
}
|
|
|
|
norm := normalizePath(relPath)
|
|
var cachedSize int64
|
|
var cachedModTime int64
|
|
var image []byte
|
|
err := s.db.QueryRow(
|
|
`SELECT file_size, mod_time_ns, image FROM preview_thumbnail_cache WHERE user_id = ? AND rel_path = ? AND renderer = ?`,
|
|
uid, norm, renderer,
|
|
).Scan(&cachedSize, &cachedModTime, &image)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
if cachedSize != size || cachedModTime != modTime.UTC().UnixNano() {
|
|
_, _ = s.db.Exec(`DELETE FROM preview_thumbnail_cache WHERE user_id = ? AND rel_path = ? AND renderer = ?`, uid, norm, renderer)
|
|
return nil, false
|
|
}
|
|
return image, true
|
|
}
|
|
|
|
func (s *Server) storePersistedPreviewThumbnail(uid int64, relPath, renderer string, size int64, modTime time.Time, contentType string, image []byte) {
|
|
if renderer == "" || len(image) == 0 || len(image) > maxPreviewThumbnailBytes {
|
|
return
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO preview_thumbnail_cache(user_id, rel_path, renderer, file_size, mod_time_ns, content_type, image, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(user_id, rel_path, renderer) DO UPDATE SET
|
|
file_size = excluded.file_size,
|
|
mod_time_ns = excluded.mod_time_ns,
|
|
content_type = excluded.content_type,
|
|
image = excluded.image,
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
uid,
|
|
normalizePath(relPath),
|
|
renderer,
|
|
size,
|
|
modTime.UTC().UnixNano(),
|
|
contentType,
|
|
image,
|
|
)
|
|
if err != nil {
|
|
log.Printf("file.thumbnail.cache.store path=%q renderer=%s error=%v", normalizePath(relPath), renderer, err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) purgePersistedPreviewThumbnail(uid int64, relPath string) {
|
|
norm := normalizePath(relPath)
|
|
_, _ = s.db.Exec(`DELETE FROM preview_thumbnail_cache WHERE user_id = ? AND (rel_path = ? OR rel_path LIKE ?)`, uid, norm, strings.TrimSuffix(norm, "/")+"/%")
|
|
}
|
|
|
|
func (s *Server) purgePersistedPreviewThumbnailForUser(uid int64) {
|
|
_, _ = s.db.Exec(`DELETE FROM preview_thumbnail_cache WHERE user_id = ?`, uid)
|
|
}
|
|
|
|
func isSearchableTextExtension(ext string) bool {
|
|
_, ok := searchableTextExtensions[ext]
|
|
return ok
|
|
}
|
|
|
|
func isSearchableZipDocumentExtension(ext string) bool {
|
|
_, ok := searchableZipDocumentExtensions[ext]
|
|
return ok
|
|
}
|
|
|
|
func isSearchableLibreOfficeExtension(ext string) bool {
|
|
_, ok := searchableLibreOfficeExtensions[ext]
|
|
return ok
|
|
}
|
|
|
|
func isSearchableImageExtension(ext string) bool {
|
|
_, ok := searchableImageExtensions[ext]
|
|
return ok
|
|
}
|
|
|
|
func extractPlainTextFile(fullPath string, force bool) (string, bool, error) {
|
|
f, err := os.Open(fullPath)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(f, maxSearchPlainTextBytes+1))
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if len(data) > maxSearchPlainTextBytes {
|
|
data = data[:maxSearchPlainTextBytes]
|
|
}
|
|
if !force && !looksLikeText(data) {
|
|
return "", false, nil
|
|
}
|
|
return decodeSearchTextBytes(data), true, nil
|
|
}
|
|
|
|
func looksLikeText(data []byte) bool {
|
|
if len(data) == 0 {
|
|
return true
|
|
}
|
|
if hasUTF16BOM(data) {
|
|
return true
|
|
}
|
|
sample := data
|
|
if len(sample) > 8192 {
|
|
sample = sample[:8192]
|
|
}
|
|
if bytes.IndexByte(sample, 0) >= 0 {
|
|
return false
|
|
}
|
|
if utf8.Valid(sample) {
|
|
return true
|
|
}
|
|
printable := 0
|
|
for _, b := range sample {
|
|
switch {
|
|
case b == '\n' || b == '\r' || b == '\t':
|
|
printable++
|
|
case b >= 0x20 && b < 0x7f:
|
|
printable++
|
|
}
|
|
}
|
|
return printable*100 >= len(sample)*90
|
|
}
|
|
|
|
func hasUTF16BOM(data []byte) bool {
|
|
return len(data) >= 2 && ((data[0] == 0xff && data[1] == 0xfe) || (data[0] == 0xfe && data[1] == 0xff))
|
|
}
|
|
|
|
func decodeSearchTextBytes(data []byte) string {
|
|
switch {
|
|
case len(data) >= 3 && data[0] == 0xef && data[1] == 0xbb && data[2] == 0xbf:
|
|
data = data[3:]
|
|
case len(data) >= 2 && data[0] == 0xff && data[1] == 0xfe:
|
|
return decodeUTF16(data[2:], binary.LittleEndian)
|
|
case len(data) >= 2 && data[0] == 0xfe && data[1] == 0xff:
|
|
return decodeUTF16(data[2:], binary.BigEndian)
|
|
}
|
|
return string(bytes.ToValidUTF8(data, []byte(" ")))
|
|
}
|
|
|
|
func decodeUTF16(data []byte, order binary.ByteOrder) string {
|
|
if len(data) < 2 {
|
|
return ""
|
|
}
|
|
u16 := make([]uint16, 0, len(data)/2)
|
|
for i := 0; i+1 < len(data); i += 2 {
|
|
u16 = append(u16, order.Uint16(data[i:i+2]))
|
|
}
|
|
return string(utf16.Decode(u16))
|
|
}
|
|
|
|
func extractZipDocumentText(fullPath, ext string) (string, error) {
|
|
reader, err := zip.OpenReader(fullPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer reader.Close()
|
|
|
|
var out strings.Builder
|
|
for _, file := range reader.File {
|
|
if out.Len() >= maxSearchExtractBytes {
|
|
break
|
|
}
|
|
if !searchableZipEntry(ext, file.Name) || file.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
rc, err := file.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := appendXMLText(&out, io.LimitReader(rc, maxSearchExtractBytes)); err != nil {
|
|
rc.Close()
|
|
continue
|
|
}
|
|
rc.Close()
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
func searchableZipEntry(ext, name string) bool {
|
|
name = strings.ToLower(name)
|
|
switch ext {
|
|
case ".docm", ".docx", ".dotm", ".dotx":
|
|
return strings.HasPrefix(name, "word/") && strings.HasSuffix(name, ".xml")
|
|
case ".xlsm", ".xlsx", ".xltm", ".xltx":
|
|
return strings.HasPrefix(name, "xl/") && strings.HasSuffix(name, ".xml")
|
|
case ".potm", ".potx", ".ppsm", ".ppsx", ".pptm", ".pptx":
|
|
return strings.HasPrefix(name, "ppt/") && strings.HasSuffix(name, ".xml")
|
|
case ".odp", ".ods", ".odt":
|
|
return name == "content.xml" || name == "styles.xml" || name == "meta.xml"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func appendXMLText(dst *strings.Builder, r io.Reader) error {
|
|
dec := xml.NewDecoder(r)
|
|
for dst.Len() < maxSearchExtractBytes {
|
|
tok, err := dec.Token()
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data, ok := tok.(xml.CharData)
|
|
if !ok {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(string(data))
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if dst.Len() > 0 {
|
|
dst.WriteByte(' ')
|
|
}
|
|
remaining := maxSearchExtractBytes - dst.Len()
|
|
if remaining <= 0 {
|
|
return nil
|
|
}
|
|
if len(text) > remaining {
|
|
text = text[:remaining]
|
|
}
|
|
dst.WriteString(text)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractPDFText(fullPath string) (string, error) {
|
|
return runSearchCommand("pdftotext", []string{"-q", "-enc", "UTF-8", fullPath, "-"})
|
|
}
|
|
|
|
func (s *Server) ocrLangs() string {
|
|
return normalizeOCRLangs(s.config.OCRLangs)
|
|
}
|
|
|
|
func extractImageOCRText(fullPath, langs string) (string, error) {
|
|
return runSearchCommand("tesseract", []string{fullPath, "stdout", "--psm", "6", "-l", normalizeOCRLangs(langs)})
|
|
}
|
|
|
|
func renderPDFThumbnail(fullPath string) ([]byte, error) {
|
|
tmpDir, err := os.MkdirTemp("", "filez-preview-pdf-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
outPrefix := filepath.Join(tmpDir, "thumb")
|
|
if err := runPreviewCommand("pdftoppm", []string{
|
|
"-png",
|
|
"-f", "1",
|
|
"-singlefile",
|
|
"-scale-to", strconv.Itoa(previewThumbnailScale),
|
|
fullPath,
|
|
outPrefix,
|
|
}); err != nil {
|
|
if fallbackErr := runPreviewCommand("pdftocairo", []string{
|
|
"-png",
|
|
"-f", "1",
|
|
"-singlefile",
|
|
"-scale-to", strconv.Itoa(previewThumbnailScale),
|
|
fullPath,
|
|
outPrefix,
|
|
}); fallbackErr != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
data, err := os.ReadFile(outPrefix + ".png")
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("thumbnail renderer did not produce an image")
|
|
}
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func renderLibreOfficeThumbnail(fullPath string) ([]byte, error) {
|
|
tmpDir, err := os.MkdirTemp("", "filez-preview-doc-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
pdfPath, err := convertLibreOfficeDocument(fullPath, tmpDir, "pdf")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return renderPDFThumbnail(pdfPath)
|
|
}
|
|
|
|
func renderTextThumbnail(text string) ([]byte, error) {
|
|
text = compactPreviewThumbnailText(text)
|
|
if text == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), previewRenderTimeout)
|
|
defer cancel()
|
|
|
|
var stdout bytes.Buffer
|
|
stderr := &limitedBuffer{limit: 16 << 10}
|
|
cmd := exec.CommandContext(ctx,
|
|
"convert",
|
|
"-background", "#f3f0e8",
|
|
"-fill", "#111111",
|
|
"-font", "DejaVu-Sans-Bold",
|
|
"-size", "960x720",
|
|
"caption:"+text,
|
|
"png:-",
|
|
)
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
return nil, errors.New(msg)
|
|
}
|
|
return nil, err
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func extractLibreOfficeText(fullPath string) (string, error) {
|
|
tmpDir, err := os.MkdirTemp("", "filez-search-doc-*")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
profileDir := filepath.Join(tmpDir, "profile")
|
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), searchExtractTimeout)
|
|
defer cancel()
|
|
|
|
stdout := &limitedBuffer{limit: 8 << 10}
|
|
stderr := &limitedBuffer{limit: 16 << 10}
|
|
cmd := exec.CommandContext(ctx,
|
|
"soffice",
|
|
"-env:UserInstallation=file://"+filepath.ToSlash(profileDir),
|
|
"--headless",
|
|
"--nologo",
|
|
"--nodefault",
|
|
"--nolockcheck",
|
|
"--nofirststartwizard",
|
|
"--convert-to", "txt:Text",
|
|
"--outdir", tmpDir,
|
|
fullPath,
|
|
)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(stdout.String())
|
|
}
|
|
if msg != "" {
|
|
return "", errors.New(msg)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
entries, err := os.ReadDir(tmpDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".txt") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(data) > maxSearchExtractBytes {
|
|
data = data[:maxSearchExtractBytes]
|
|
}
|
|
return decodeSearchTextBytes(data), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func convertLibreOfficeDocument(fullPath, tmpDir, format string) (string, error) {
|
|
profileDir := filepath.Join(tmpDir, "profile")
|
|
if err := os.MkdirAll(profileDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), previewRenderTimeout)
|
|
defer cancel()
|
|
|
|
stdout := &limitedBuffer{limit: 8 << 10}
|
|
stderr := &limitedBuffer{limit: 16 << 10}
|
|
cmd := exec.CommandContext(ctx,
|
|
"soffice",
|
|
"-env:UserInstallation=file://"+filepath.ToSlash(profileDir),
|
|
"--headless",
|
|
"--nologo",
|
|
"--nodefault",
|
|
"--nolockcheck",
|
|
"--nofirststartwizard",
|
|
"--convert-to", format,
|
|
"--outdir", tmpDir,
|
|
fullPath,
|
|
)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(stdout.String())
|
|
}
|
|
if msg != "" {
|
|
return "", errors.New(msg)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
wantExt := "." + strings.ToLower(strings.TrimPrefix(format, "."))
|
|
entries, err := os.ReadDir(tmpDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), wantExt) {
|
|
continue
|
|
}
|
|
return filepath.Join(tmpDir, entry.Name()), nil
|
|
}
|
|
return "", fmt.Errorf("libreoffice did not produce a %s file", wantExt)
|
|
}
|
|
|
|
func compactPreviewThumbnailText(text string) string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return ""
|
|
}
|
|
|
|
words := strings.Fields(text)
|
|
if len(words) == 0 {
|
|
return ""
|
|
}
|
|
|
|
joined := strings.Join(words, " ")
|
|
runes := []rune(joined)
|
|
if len(runes) > 420 {
|
|
joined = string(runes[:420]) + "..."
|
|
}
|
|
return joined
|
|
}
|
|
|
|
func runSearchCommand(name string, args []string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), searchExtractTimeout)
|
|
defer cancel()
|
|
|
|
stdout := &limitedBuffer{limit: maxSearchExtractBytes}
|
|
stderr := &limitedBuffer{limit: 16 << 10}
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
return "", errors.New(msg)
|
|
}
|
|
return "", err
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
func runPreviewCommand(name string, args []string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), previewRenderTimeout)
|
|
defer cancel()
|
|
|
|
stdout := &limitedBuffer{limit: 8 << 10}
|
|
stderr := &limitedBuffer{limit: 16 << 10}
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = strings.TrimSpace(stdout.String())
|
|
}
|
|
if msg != "" {
|
|
return errors.New(msg)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeSearchText(v string) string {
|
|
var out strings.Builder
|
|
lastSpace := true
|
|
for _, ch := range strings.ToLower(v) {
|
|
if ch == 0 {
|
|
continue
|
|
}
|
|
if unicode.IsControl(ch) && !unicode.IsSpace(ch) {
|
|
continue
|
|
}
|
|
if unicode.IsSpace(ch) {
|
|
if !lastSpace {
|
|
out.WriteByte(' ')
|
|
lastSpace = true
|
|
}
|
|
continue
|
|
}
|
|
out.WriteRune(ch)
|
|
lastSpace = false
|
|
}
|
|
return strings.TrimSpace(out.String())
|
|
}
|
|
|
|
func contentMatchScore(query, content string) (int, bool) {
|
|
if query == "" || content == "" {
|
|
return 0, false
|
|
}
|
|
if idx := strings.Index(content, query); idx >= 0 {
|
|
score := 1180 - minInt(idx, 2400)/4
|
|
if score < 420 {
|
|
score = 420
|
|
}
|
|
return score, true
|
|
}
|
|
|
|
terms := strings.Fields(query)
|
|
if len(terms) < 2 {
|
|
return 0, false
|
|
}
|
|
positions := make([]int, 0, len(terms))
|
|
score := 0
|
|
for _, term := range terms {
|
|
idx := strings.Index(content, term)
|
|
if idx < 0 {
|
|
return 0, false
|
|
}
|
|
positions = append(positions, idx)
|
|
score += 180
|
|
}
|
|
sort.Ints(positions)
|
|
span := positions[len(positions)-1] - positions[0]
|
|
score += 620 - minInt(span, 2400)/6
|
|
if score < 360 {
|
|
score = 360
|
|
}
|
|
return score, true
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
relDir := r.URL.Query().Get("path")
|
|
|
|
if err := r.ParseMultipartForm(256 << 20); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "failed to parse upload")
|
|
return
|
|
}
|
|
|
|
files := r.MultipartForm.File["file"]
|
|
if len(files) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "no files provided")
|
|
return
|
|
}
|
|
|
|
for _, fh := range files {
|
|
log.Printf("file.upload user_id=%d dir=%q name=%q size=%d", uid, normalizePath(relDir), fh.Filename, fh.Size)
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, fmt.Sprintf("cannot open %s", fh.Filename))
|
|
return
|
|
}
|
|
relName, err := normalizeUploadRelativePath(fh.Filename)
|
|
if err != nil {
|
|
_ = src.Close()
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
target := path.Join(normalizePath(relDir), relName)
|
|
err = s.storage.Save(uid, target, src)
|
|
_ = src.Close()
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "uploaded"})
|
|
}
|
|
|
|
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := r.URL.Query().Get("path")
|
|
log.Printf("file.download user_id=%d path=%q", uid, normalizePath(rel))
|
|
if err := s.serveFile(w, r, uid, rel, false, r.URL.Query().Get("archive")); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleBatchDownload(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in struct {
|
|
Paths []string `json:"paths"`
|
|
Archive string `json:"archive"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
if len(in.Paths) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "no paths selected")
|
|
return
|
|
}
|
|
if len(in.Paths) > 200 {
|
|
writeErr(w, http.StatusBadRequest, "too many selected paths")
|
|
return
|
|
}
|
|
|
|
format, err := s.resolveArchiveFormat(uid, in.Archive)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
archivePath, name, ctype, err := s.createBatchArchiveTemp(uid, in.Paths, format)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
defer os.Remove(archivePath)
|
|
|
|
f, err := os.Open(archivePath)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to open archive")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to stat archive")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Content-Type", ctype)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
|
http.ServeContent(w, r, name, st.ModTime(), f)
|
|
}
|
|
|
|
func (s *Server) handleBatchMove(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in struct {
|
|
Paths []string `json:"paths"`
|
|
Destination string `json:"destination"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
if len(in.Paths) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "no paths selected")
|
|
return
|
|
}
|
|
destDir := normalizePath(in.Destination)
|
|
if err := s.storage.Mkdir(uid, destDir); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
moved := 0
|
|
for _, p := range in.Paths {
|
|
src := normalizePath(p)
|
|
if src == "/" {
|
|
continue
|
|
}
|
|
meta, err := s.storage.Stat(uid, src)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid path")
|
|
return
|
|
}
|
|
if meta.IsDir {
|
|
srcPrefix := strings.TrimSuffix(src, "/") + "/"
|
|
if destDir == src || strings.HasPrefix(destDir, srcPrefix) {
|
|
writeErr(w, http.StatusBadRequest, "cannot move a folder into itself")
|
|
return
|
|
}
|
|
}
|
|
base := path.Base(src)
|
|
dst := path.Join(destDir, base)
|
|
dst, err = s.nextMoveTarget(uid, dst)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := s.copyPath(uid, src, dst); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := s.storage.Delete(uid, src); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
s.moveTags(uid, src, dst)
|
|
s.purgePersistedSearchContent(uid, src)
|
|
s.purgePersistedSearchContent(uid, dst)
|
|
s.purgePersistedPreviewThumbnail(uid, src)
|
|
s.purgePersistedPreviewThumbnail(uid, dst)
|
|
moved++
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "moved", "count": moved})
|
|
}
|
|
|
|
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := r.URL.Query().Get("path")
|
|
log.Printf("file.preview user_id=%d path=%q", uid, normalizePath(rel))
|
|
if err := s.serveFile(w, r, uid, rel, true, ""); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := normalizePath(r.URL.Query().Get("path"))
|
|
if rel == "/" {
|
|
writeErr(w, http.StatusBadRequest, "path is required")
|
|
return
|
|
}
|
|
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil || meta.IsDir {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
|
|
entry := FileEntry{
|
|
Name: meta.Name,
|
|
Path: rel,
|
|
IsDir: meta.IsDir,
|
|
Size: meta.Size,
|
|
ModTime: meta.ModTime,
|
|
}
|
|
data, err := s.extractPreviewThumbnail(uid, entry)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if len(data) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "thumbnail unavailable for this file type")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "private, max-age=300")
|
|
w.Header().Set("Content-Type", "image/png")
|
|
http.ServeContent(w, r, entry.Name+".png", entry.ModTime, bytes.NewReader(data))
|
|
}
|
|
|
|
func (s *Server) handleContentPreview(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := normalizePath(r.URL.Query().Get("path"))
|
|
if rel == "/" {
|
|
writeErr(w, http.StatusBadRequest, "path is required")
|
|
return
|
|
}
|
|
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil || meta.IsDir {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
|
|
entry := FileEntry{
|
|
Name: meta.Name,
|
|
Path: rel,
|
|
IsDir: meta.IsDir,
|
|
Size: meta.Size,
|
|
ModTime: meta.ModTime,
|
|
}
|
|
text, err := s.extractPreviewText(uid, entry)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
writeErr(w, http.StatusBadRequest, "preview unavailable for this file type")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "content": text})
|
|
}
|
|
|
|
func (s *Server) handleReadTextFile(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := normalizePath(r.URL.Query().Get("path"))
|
|
if rel == "/" {
|
|
writeErr(w, http.StatusBadRequest, "path is required")
|
|
return
|
|
}
|
|
if !isMarkdownExtension(path.Ext(rel)) {
|
|
writeErr(w, http.StatusBadRequest, "only markdown files are editable")
|
|
return
|
|
}
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil || meta.IsDir {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
rc, err := s.storage.OpenReadSeeker(uid, rel)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
defer rc.Close()
|
|
|
|
const maxTextBytes = 2 << 20
|
|
data, err := io.ReadAll(io.LimitReader(rc, maxTextBytes+1))
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to read file")
|
|
return
|
|
}
|
|
if len(data) > maxTextBytes {
|
|
writeErr(w, http.StatusBadRequest, "markdown file is too large (max 2MB)")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "content": string(data), "size": len(data)})
|
|
}
|
|
|
|
func (s *Server) handleWriteTextFile(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
rel := normalizePath(in.Path)
|
|
if rel == "/" {
|
|
writeErr(w, http.StatusBadRequest, "path is required")
|
|
return
|
|
}
|
|
if !isMarkdownExtension(path.Ext(rel)) {
|
|
writeErr(w, http.StatusBadRequest, "only markdown files are editable")
|
|
return
|
|
}
|
|
if len(in.Content) > 2<<20 {
|
|
writeErr(w, http.StatusBadRequest, "markdown file is too large (max 2MB)")
|
|
return
|
|
}
|
|
if err := s.storage.SaveBytes(uid, rel, []byte(in.Content)); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "status": "saved"})
|
|
}
|
|
|
|
func (s *Server) handleListFileTags(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := normalizePath(r.URL.Query().Get("path"))
|
|
rows, err := s.db.Query(`SELECT tag FROM file_tags WHERE user_id = ? AND rel_path = ? ORDER BY tag ASC`, uid, rel)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to load tags")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
tags := make([]string, 0)
|
|
for rows.Next() {
|
|
var tag string
|
|
if rows.Scan(&tag) == nil {
|
|
tags = append(tags, tag)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "tags": tags})
|
|
}
|
|
|
|
func (s *Server) handleAddFileTag(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in struct {
|
|
Path string `json:"path"`
|
|
Tag string `json:"tag"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
rel := normalizePath(in.Path)
|
|
tag, ok := normalizeTag(in.Tag)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "tag must be 1-24 chars: a-z 0-9 dash underscore")
|
|
return
|
|
}
|
|
if _, err := s.storage.Stat(uid, rel); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
if _, err := s.db.Exec(`INSERT OR IGNORE INTO file_tags(user_id, rel_path, tag) VALUES (?, ?, ?)`, uid, rel, tag); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to save tag")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"path": rel, "tag": tag})
|
|
}
|
|
|
|
func (s *Server) handleDeleteFileTag(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := normalizePath(r.URL.Query().Get("path"))
|
|
tag, ok := normalizeTag(r.URL.Query().Get("tag"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid tag")
|
|
return
|
|
}
|
|
if _, err := s.db.Exec(`DELETE FROM file_tags WHERE user_id = ? AND rel_path = ? AND tag = ?`, uid, rel, tag); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to delete tag")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": rel, "tag": tag, "status": "deleted"})
|
|
}
|
|
|
|
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, uid int64, rel string, inline bool, archiveQuery string) error {
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if meta.IsDir {
|
|
if inline {
|
|
return fmt.Errorf("cannot preview a directory")
|
|
}
|
|
format, err := s.resolveArchiveFormat(uid, archiveQuery)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.serveDirectoryArchive(w, r, uid, rel, format)
|
|
}
|
|
|
|
rc, err := s.storage.OpenReadSeeker(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
name := path.Base(normalizePath(rel))
|
|
if name == "." || name == "/" || name == "" {
|
|
name = "download"
|
|
}
|
|
ctype := mime.TypeByExtension(strings.ToLower(filepath.Ext(name)))
|
|
if ctype == "" {
|
|
ctype = "application/octet-stream"
|
|
}
|
|
dispositionType := "attachment"
|
|
if inline {
|
|
dispositionType = "inline"
|
|
}
|
|
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Content-Type", ctype)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q", dispositionType, name))
|
|
http.ServeContent(w, r, name, meta.ModTime, rc)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) resolveArchiveFormat(uid int64, archiveQuery string) (string, error) {
|
|
requested := normalizeArchiveFormat(archiveQuery)
|
|
if requested != "" {
|
|
return requested, nil
|
|
}
|
|
var fmtPref string
|
|
err := s.db.QueryRow(`SELECT archive_format FROM users WHERE id = ?`, uid).Scan(&fmtPref)
|
|
if err != nil {
|
|
return "zip", nil
|
|
}
|
|
fmtPref = normalizeArchiveFormat(fmtPref)
|
|
if fmtPref == "" {
|
|
return "zip", nil
|
|
}
|
|
return fmtPref, nil
|
|
}
|
|
|
|
func (s *Server) serveDirectoryArchive(w http.ResponseWriter, r *http.Request, uid int64, rel, format string) error {
|
|
base := path.Base(normalizePath(rel))
|
|
if base == "/" || base == "." || base == "" {
|
|
base = "folder"
|
|
}
|
|
|
|
archivePath, downloadName, ctype, err := s.createArchiveTemp(uid, rel, base, format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(archivePath)
|
|
|
|
f, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
w.Header().Set("Content-Type", ctype)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName))
|
|
http.ServeContent(w, r, downloadName, st.ModTime(), f)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createArchiveTemp(uid int64, rel, base, format string) (string, string, string, error) {
|
|
switch format {
|
|
case "rar":
|
|
return s.createRarTemp(uid, rel, base)
|
|
case "tar.gz":
|
|
return s.createTarGzTemp(uid, rel, base)
|
|
case "lz4":
|
|
return s.createTarLz4Temp(uid, rel, base)
|
|
default:
|
|
return s.createZipTemp(uid, rel, base)
|
|
}
|
|
}
|
|
|
|
func (s *Server) createBatchArchiveTemp(uid int64, paths []string, format string) (string, string, string, error) {
|
|
workdir, err := os.MkdirTemp("", "filez-batch-*")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer os.RemoveAll(workdir)
|
|
|
|
root := filepath.Join(workdir, "selection")
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
used := map[string]int{}
|
|
for _, raw := range paths {
|
|
rel := normalizePath(raw)
|
|
if rel == "/" {
|
|
continue
|
|
}
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("invalid path: %s", rel)
|
|
}
|
|
base := path.Base(rel)
|
|
if base == "." || base == "/" || base == "" {
|
|
base = "item"
|
|
}
|
|
targetName := uniqueArchiveName(base, used)
|
|
target := filepath.Join(root, targetName)
|
|
if meta.IsDir {
|
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
}
|
|
if err := s.materializePath(uid, rel, target); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
}
|
|
|
|
baseName := "files"
|
|
switch format {
|
|
case "tar.gz":
|
|
return createTarGzFromLocalDir(root, baseName)
|
|
case "lz4":
|
|
return createTarLz4FromLocalDir(root, baseName)
|
|
case "rar":
|
|
return createRarFromLocalDir(root, baseName)
|
|
default:
|
|
return createZipFromLocalDir(root, baseName)
|
|
}
|
|
}
|
|
|
|
func (s *Server) createZipTemp(uid int64, rel, base string) (string, string, string, error) {
|
|
tmp, err := os.CreateTemp("", "filez-*.zip")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer tmp.Close()
|
|
|
|
zw := zip.NewWriter(tmp)
|
|
if err := s.addPathToZip(zw, uid, rel, base); err != nil {
|
|
zw.Close()
|
|
return "", "", "", err
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return tmp.Name(), base + ".zip", "application/zip", nil
|
|
}
|
|
|
|
func (s *Server) addPathToZip(zw *zip.Writer, uid int64, rel string, zipBase string) error {
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if meta.IsDir {
|
|
if zipBase != "" {
|
|
hdr := &zip.FileHeader{Name: strings.TrimPrefix(zipBase, "/") + "/", Method: zip.Store}
|
|
hdr.Modified = meta.ModTime
|
|
if _, err := zw.CreateHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
entries, err := s.storage.List(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
childRel := path.Join(normalizePath(rel), e.Name)
|
|
childBase := path.Join(zipBase, e.Name)
|
|
if err := s.addPathToZip(zw, uid, childRel, childBase); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
rc, err := s.storage.OpenReadSeeker(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
hdr := &zip.FileHeader{Name: strings.TrimPrefix(zipBase, "/"), Method: zip.Deflate}
|
|
hdr.Modified = meta.ModTime
|
|
writer, err := zw.CreateHeader(hdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(writer, rc)
|
|
return err
|
|
}
|
|
|
|
func (s *Server) createRarTemp(uid int64, rel, base string) (string, string, string, error) {
|
|
if _, err := exec.LookPath("rar"); err != nil {
|
|
return "", "", "", fmt.Errorf("rar format requires 'rar' binary on server; choose zip or tar.gz in settings")
|
|
}
|
|
|
|
workdir, err := os.MkdirTemp("", "filez-rar-*")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
root := filepath.Join(workdir, base)
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
os.RemoveAll(workdir)
|
|
return "", "", "", err
|
|
}
|
|
|
|
if err := s.materializePath(uid, rel, root); err != nil {
|
|
os.RemoveAll(workdir)
|
|
return "", "", "", err
|
|
}
|
|
|
|
outTmp, err := os.CreateTemp("", "filez-*.rar")
|
|
if err != nil {
|
|
os.RemoveAll(workdir)
|
|
return "", "", "", err
|
|
}
|
|
archivePath := outTmp.Name()
|
|
outTmp.Close()
|
|
|
|
cmd := exec.Command("rar", "a", "-idq", archivePath, root)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
os.Remove(archivePath)
|
|
os.RemoveAll(workdir)
|
|
return "", "", "", fmt.Errorf("rar creation failed: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
os.RemoveAll(workdir)
|
|
|
|
return archivePath, base + ".rar", "application/vnd.rar", nil
|
|
}
|
|
|
|
func (s *Server) createTarGzTemp(uid int64, rel, base string) (string, string, string, error) {
|
|
tmp, err := os.CreateTemp("", "filez-*.tar.gz")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer tmp.Close()
|
|
|
|
gzw := gzip.NewWriter(tmp)
|
|
tw := tar.NewWriter(gzw)
|
|
if err := s.addPathToTar(tw, uid, rel, base); err != nil {
|
|
tw.Close()
|
|
gzw.Close()
|
|
return "", "", "", err
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
gzw.Close()
|
|
return "", "", "", err
|
|
}
|
|
if err := gzw.Close(); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return tmp.Name(), base + ".tar.gz", "application/gzip", nil
|
|
}
|
|
|
|
func (s *Server) createTarLz4Temp(uid int64, rel, base string) (string, string, string, error) {
|
|
if _, err := exec.LookPath("lz4"); err != nil {
|
|
return "", "", "", fmt.Errorf("lz4 format requires 'lz4' binary on server; choose zip or tar.gz in settings")
|
|
}
|
|
cleanupStaleTempArchives(1 * time.Hour)
|
|
tarPath, err := s.createTarTemp(uid, rel, base)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer os.Remove(tarPath)
|
|
|
|
outTmp, err := os.CreateTemp("", "filez-*.tar.lz4")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
outPath := outTmp.Name()
|
|
outTmp.Close()
|
|
_ = os.Remove(outPath)
|
|
|
|
cmd := exec.Command("lz4", "-z", "-q", tarPath, outPath)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
os.Remove(outPath)
|
|
return "", "", "", fmt.Errorf("lz4 creation failed: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
return outPath, base + ".tar.lz4", "application/x-lz4", nil
|
|
}
|
|
|
|
func (s *Server) createTarTemp(uid int64, rel, base string) (string, error) {
|
|
tmp, err := os.CreateTemp("", "filez-*.tar")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tmp.Close()
|
|
|
|
tw := tar.NewWriter(tmp)
|
|
if err := s.addPathToTar(tw, uid, rel, base); err != nil {
|
|
tw.Close()
|
|
return "", err
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
return tmp.Name(), nil
|
|
}
|
|
|
|
func (s *Server) addPathToTar(tw *tar.Writer, uid int64, rel string, tarBase string) error {
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if meta.IsDir {
|
|
if tarBase != "" {
|
|
hdr := &tar.Header{Name: strings.TrimPrefix(tarBase, "/") + "/", Mode: 0o755, ModTime: meta.ModTime, Typeflag: tar.TypeDir}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
entries, err := s.storage.List(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
childRel := path.Join(normalizePath(rel), e.Name)
|
|
childBase := path.Join(tarBase, e.Name)
|
|
if err := s.addPathToTar(tw, uid, childRel, childBase); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
rc, err := s.storage.OpenReadSeeker(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
hdr := &tar.Header{Name: strings.TrimPrefix(tarBase, "/"), Mode: 0o644, Size: meta.Size, ModTime: meta.ModTime, Typeflag: tar.TypeReg}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
_, err = io.Copy(tw, rc)
|
|
return err
|
|
}
|
|
|
|
func (s *Server) materializePath(uid int64, rel string, localPath string) error {
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if meta.IsDir {
|
|
entries, err := s.storage.List(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
childRel := path.Join(normalizePath(rel), e.Name)
|
|
childLocal := filepath.Join(localPath, e.Name)
|
|
if e.IsDir {
|
|
if err := os.MkdirAll(childLocal, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := s.materializePath(uid, childRel, childLocal); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
rc, err := s.storage.OpenReadSeeker(uid, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
|
|
out, err := os.Create(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, rc)
|
|
return err
|
|
}
|
|
|
|
func (s *Server) copyPath(uid int64, src, dst string) error {
|
|
meta, err := s.storage.Stat(uid, src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if meta.IsDir {
|
|
if err := s.storage.Mkdir(uid, dst); err != nil {
|
|
return err
|
|
}
|
|
entries, err := s.storage.List(uid, src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
childSrc := path.Join(src, e.Name)
|
|
childDst := path.Join(dst, e.Name)
|
|
if err := s.copyPath(uid, childSrc, childDst); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
rc, err := s.storage.OpenReadSeeker(uid, src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rc.Close()
|
|
data, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.storage.SaveBytes(uid, dst, data)
|
|
}
|
|
|
|
func (s *Server) nextMoveTarget(uid int64, dst string) (string, error) {
|
|
norm := normalizePath(dst)
|
|
if _, err := s.storage.Stat(uid, norm); err != nil {
|
|
return norm, nil
|
|
}
|
|
ext := path.Ext(norm)
|
|
base := strings.TrimSuffix(path.Base(norm), ext)
|
|
dir := path.Dir(norm)
|
|
for i := 2; i <= 9999; i++ {
|
|
candidate := path.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext))
|
|
if _, err := s.storage.Stat(uid, candidate); err != nil {
|
|
return candidate, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("cannot allocate destination name")
|
|
}
|
|
|
|
func (s *Server) moveTags(uid int64, src, dst string) {
|
|
_, _ = s.db.Exec(`UPDATE file_tags SET rel_path = ? WHERE user_id = ? AND rel_path = ?`, dst, uid, src)
|
|
prefixFrom := strings.TrimSuffix(src, "/") + "/"
|
|
prefixTo := strings.TrimSuffix(dst, "/") + "/"
|
|
rows, err := s.db.Query(`SELECT rel_path, tag FROM file_tags WHERE user_id = ? AND rel_path LIKE ?`, uid, prefixFrom+"%")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
type rowItem struct{ p, t string }
|
|
items := make([]rowItem, 0)
|
|
for rows.Next() {
|
|
var rp, tg string
|
|
if rows.Scan(&rp, &tg) == nil {
|
|
items = append(items, rowItem{p: rp, t: tg})
|
|
}
|
|
}
|
|
for _, it := range items {
|
|
next := prefixTo + strings.TrimPrefix(it.p, prefixFrom)
|
|
_, _ = s.db.Exec(`DELETE FROM file_tags WHERE user_id = ? AND rel_path = ? AND tag = ?`, uid, it.p, it.t)
|
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO file_tags(user_id, rel_path, tag) VALUES (?, ?, ?)`, uid, next, it.t)
|
|
}
|
|
}
|
|
|
|
func uniqueArchiveName(base string, used map[string]int) string {
|
|
base = strings.TrimSpace(base)
|
|
if base == "" {
|
|
base = "item"
|
|
}
|
|
count := used[base]
|
|
used[base] = count + 1
|
|
if count == 0 {
|
|
return base
|
|
}
|
|
ext := path.Ext(base)
|
|
name := strings.TrimSuffix(base, ext)
|
|
return fmt.Sprintf("%s-%d%s", name, count+1, ext)
|
|
}
|
|
|
|
func createZipFromLocalDir(root, baseName string) (string, string, string, error) {
|
|
tmp, err := os.CreateTemp("", "filez-batch-*.zip")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer tmp.Close()
|
|
|
|
zw := zip.NewWriter(tmp)
|
|
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
hdr := &zip.FileHeader{Name: rel + "/", Method: zip.Store}
|
|
hdr.Modified = info.ModTime()
|
|
_, err = zw.CreateHeader(hdr)
|
|
return err
|
|
}
|
|
hdr, err := zip.FileInfoHeader(info)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr.Name = rel
|
|
hdr.Method = zip.Deflate
|
|
w, err := zw.CreateHeader(hdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(w, f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
zw.Close()
|
|
return "", "", "", err
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return tmp.Name(), baseName + ".zip", "application/zip", nil
|
|
}
|
|
|
|
func createTarGzFromLocalDir(root, baseName string) (string, string, string, error) {
|
|
tmp, err := os.CreateTemp("", "filez-batch-*.tar.gz")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer tmp.Close()
|
|
gzw := gzip.NewWriter(tmp)
|
|
tw := tar.NewWriter(gzw)
|
|
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr, err := tar.FileInfoHeader(info, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
hdr.Name = rel + "/"
|
|
} else {
|
|
hdr.Name = rel
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(tw, f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
tw.Close()
|
|
gzw.Close()
|
|
return "", "", "", err
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
gzw.Close()
|
|
return "", "", "", err
|
|
}
|
|
if err := gzw.Close(); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return tmp.Name(), baseName + ".tar.gz", "application/gzip", nil
|
|
}
|
|
|
|
func createTarFromLocalDir(root string) (string, error) {
|
|
tmp, err := os.CreateTemp("", "filez-batch-*.tar")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer tmp.Close()
|
|
tw := tar.NewWriter(tmp)
|
|
err = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hdr, err := tar.FileInfoHeader(info, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
hdr.Name = rel + "/"
|
|
} else {
|
|
hdr.Name = rel
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(tw, f)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
tw.Close()
|
|
return "", err
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
return tmp.Name(), nil
|
|
}
|
|
|
|
func createTarLz4FromLocalDir(root, baseName string) (string, string, string, error) {
|
|
if _, err := exec.LookPath("lz4"); err != nil {
|
|
return "", "", "", fmt.Errorf("lz4 format requires 'lz4' binary on server; choose zip or tar.gz in settings")
|
|
}
|
|
cleanupStaleTempArchives(1 * time.Hour)
|
|
tarPath, err := createTarFromLocalDir(root)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
defer os.Remove(tarPath)
|
|
|
|
outTmp, err := os.CreateTemp("", "filez-batch-*.tar.lz4")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
outPath := outTmp.Name()
|
|
outTmp.Close()
|
|
_ = os.Remove(outPath)
|
|
cmd := exec.Command("lz4", "-z", "-q", tarPath, outPath)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
os.Remove(outPath)
|
|
return "", "", "", fmt.Errorf("lz4 creation failed: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
return outPath, baseName + ".tar.lz4", "application/x-lz4", nil
|
|
}
|
|
|
|
func cleanupStaleTempArchives(maxIdle time.Duration) {
|
|
tmpDir := os.TempDir()
|
|
entries, err := os.ReadDir(tmpDir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
cutoff := time.Now().Add(-maxIdle)
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if !strings.HasPrefix(name, "filez-") && !strings.HasPrefix(name, "filez-batch-") {
|
|
continue
|
|
}
|
|
if !(strings.HasSuffix(name, ".tar.lz4") || strings.HasSuffix(name, ".tar") || strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".zip") || strings.HasSuffix(name, ".rar")) {
|
|
continue
|
|
}
|
|
|
|
info, err := entry.Info()
|
|
if err != nil || info.IsDir() {
|
|
continue
|
|
}
|
|
if info.ModTime().After(cutoff) {
|
|
continue
|
|
}
|
|
|
|
_ = os.Remove(filepath.Join(tmpDir, name))
|
|
}
|
|
}
|
|
|
|
func createRarFromLocalDir(root, baseName string) (string, string, string, error) {
|
|
if _, err := exec.LookPath("rar"); err != nil {
|
|
return "", "", "", fmt.Errorf("rar format requires 'rar' binary on server; choose zip or tar.gz in settings")
|
|
}
|
|
outTmp, err := os.CreateTemp("", "filez-batch-*.rar")
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
outPath := outTmp.Name()
|
|
outTmp.Close()
|
|
cmd := exec.Command("rar", "a", "-idq", outPath, root)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
os.Remove(outPath)
|
|
return "", "", "", fmt.Errorf("rar creation failed: %s", strings.TrimSpace(string(out)))
|
|
}
|
|
return outPath, baseName + ".rar", "application/vnd.rar", nil
|
|
}
|
|
|
|
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
rel := r.URL.Query().Get("path")
|
|
norm := normalizePath(rel)
|
|
log.Printf("file.delete user_id=%d path=%q", uid, norm)
|
|
if err := s.storage.Delete(uid, norm); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM file_tags WHERE user_id = ? AND (rel_path = ? OR rel_path LIKE ?)`, uid, norm, strings.TrimSuffix(norm, "/")+"/%")
|
|
s.purgePersistedSearchContent(uid, norm)
|
|
s.purgePersistedPreviewThumbnail(uid, norm)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
|
type payload struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
}
|
|
var in payload
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
uid := userIDFromContext(r.Context())
|
|
rel := path.Join(normalizePath(in.Path), path.Base(strings.TrimSpace(in.Name)))
|
|
log.Printf("file.mkdir user_id=%d path=%q", uid, normalizePath(rel))
|
|
if rel == "/" || rel == "." {
|
|
writeErr(w, http.StatusBadRequest, "invalid folder name")
|
|
return
|
|
}
|
|
if err := s.storage.Mkdir(uid, rel); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "created"})
|
|
}
|
|
|
|
func (s *Server) handleRename(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
src := normalizePath(in.Path)
|
|
if src == "/" {
|
|
writeErr(w, http.StatusBadRequest, "invalid path")
|
|
return
|
|
}
|
|
meta, err := s.storage.Stat(uid, src)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
|
|
name := path.Base(strings.TrimSpace(in.Name))
|
|
if name == "" || name == "." || name == ".." {
|
|
writeErr(w, http.StatusBadRequest, "invalid name")
|
|
return
|
|
}
|
|
|
|
dir := path.Dir(src)
|
|
if dir == "." {
|
|
dir = "/"
|
|
}
|
|
dst := normalizePath(path.Join(dir, name))
|
|
if dst == src {
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "renamed", "path": src})
|
|
return
|
|
}
|
|
if _, err := s.storage.Stat(uid, dst); err == nil {
|
|
writeErr(w, http.StatusBadRequest, "target already exists")
|
|
return
|
|
}
|
|
if meta.IsDir {
|
|
srcPrefix := strings.TrimSuffix(src, "/") + "/"
|
|
if dst == src || strings.HasPrefix(dst, srcPrefix) {
|
|
writeErr(w, http.StatusBadRequest, "cannot rename folder into itself")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := s.copyPath(uid, src, dst); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := s.storage.Delete(uid, src); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
s.moveTags(uid, src, dst)
|
|
s.purgePersistedSearchContent(uid, src)
|
|
s.purgePersistedSearchContent(uid, dst)
|
|
s.purgePersistedPreviewThumbnail(uid, src)
|
|
s.purgePersistedPreviewThumbnail(uid, dst)
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "renamed", "path": dst})
|
|
}
|
|
|
|
type shareInput struct {
|
|
Path string `json:"path"`
|
|
ExpiresMinutes int `json:"expiresMinutes"`
|
|
}
|
|
|
|
func (s *Server) handleCreateShareLink(w http.ResponseWriter, r *http.Request) {
|
|
uid := userIDFromContext(r.Context())
|
|
var in shareInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
rel := normalizePath(in.Path)
|
|
log.Printf("file.share.create user_id=%d path=%q", uid, rel)
|
|
meta, err := s.storage.Stat(uid, rel)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "file not found")
|
|
return
|
|
}
|
|
if meta.IsDir {
|
|
writeErr(w, http.StatusBadRequest, "cannot create share for folder")
|
|
return
|
|
}
|
|
|
|
ttl := s.config.ShareDefaultTTL
|
|
if in.ExpiresMinutes > 0 {
|
|
ttl = time.Duration(in.ExpiresMinutes) * time.Minute
|
|
}
|
|
if ttl < 5*time.Minute {
|
|
ttl = 5 * time.Minute
|
|
}
|
|
if ttl > 30*24*time.Hour {
|
|
ttl = 30 * 24 * time.Hour
|
|
}
|
|
|
|
token, err := randomToken()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to create token")
|
|
return
|
|
}
|
|
|
|
expiresAt := time.Now().Add(ttl)
|
|
_, err = s.db.Exec(`INSERT INTO share_links(user_id, rel_path, token_hash, expires_at, max_downloads) VALUES (?, ?, ?, ?, ?)`,
|
|
uid,
|
|
rel,
|
|
hashToken(token),
|
|
expiresAt,
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to create share")
|
|
return
|
|
}
|
|
|
|
shareURL := fmt.Sprintf("%s://%s/share/%s", schemeOf(r), r.Host, token)
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"url": shareURL,
|
|
"token": token,
|
|
"path": rel,
|
|
"expiresAt": expiresAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSharedDownload(w http.ResponseWriter, r *http.Request) {
|
|
record, token, err := s.lookupActiveShare(r)
|
|
if err != nil {
|
|
writeErr(w, shareHTTPStatus(err), err.Error())
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodHead {
|
|
if _, err := s.db.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token_hash = ?`, hashToken(token)); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to track download")
|
|
return
|
|
}
|
|
}
|
|
|
|
log.Printf("file.share.download user_id=%d path=%q ip=%q", record.UserID, normalizePath(record.RelPath), clientIP(r))
|
|
|
|
if err := s.serveFile(w, r, record.UserID, record.RelPath, true, ""); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleSharedPreview(w http.ResponseWriter, r *http.Request) {
|
|
record, _, err := s.lookupActiveShare(r)
|
|
if err != nil {
|
|
writeErr(w, shareHTTPStatus(err), err.Error())
|
|
return
|
|
}
|
|
|
|
if err := s.serveFile(w, r, record.UserID, record.RelPath, true, ""); err != nil {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleSharedPage(w http.ResponseWriter, r *http.Request) {
|
|
record, token, err := s.lookupActiveShare(r)
|
|
if err != nil {
|
|
writeSharedPageStatus(w, shareHTTPStatus(err), "Share unavailable", err.Error())
|
|
return
|
|
}
|
|
|
|
meta, err := s.storage.Stat(record.UserID, record.RelPath)
|
|
if err != nil {
|
|
writeSharedPageStatus(w, http.StatusNotFound, "Share unavailable", "file not found")
|
|
return
|
|
}
|
|
|
|
name := path.Base(normalizePath(record.RelPath))
|
|
if name == "." || name == "/" || name == "" {
|
|
name = meta.Name
|
|
}
|
|
if name == "" {
|
|
name = "Shared file"
|
|
}
|
|
|
|
ctype := mime.TypeByExtension(strings.ToLower(filepath.Ext(name)))
|
|
pageURL := fmt.Sprintf("%s://%s/share/%s", schemeOf(r), r.Host, token)
|
|
downloadURL := fmt.Sprintf("%s://%s/api/share/%s", schemeOf(r), r.Host, token)
|
|
previewURL := fmt.Sprintf("%s://%s/api/share/%s/preview", schemeOf(r), r.Host, token)
|
|
description := sharedFileDescription(meta, record.ExpiresAt)
|
|
cardType := "summary"
|
|
extraMeta := ""
|
|
extraBody := ""
|
|
|
|
switch {
|
|
case strings.HasPrefix(ctype, "image/"):
|
|
cardType = "summary_large_image"
|
|
extraMeta = fmt.Sprintf(`
|
|
<meta property="og:image" content="%s">
|
|
<meta name="twitter:image" content="%s">`, html.EscapeString(previewURL), html.EscapeString(previewURL))
|
|
extraBody = fmt.Sprintf(`<div class="preview"><img src="%s" alt="%s"></div>`, html.EscapeString(previewURL), html.EscapeString(name))
|
|
case strings.HasPrefix(ctype, "video/"):
|
|
extraMeta = fmt.Sprintf(`
|
|
<meta property="og:video" content="%s">
|
|
<meta property="og:video:type" content="%s">`, html.EscapeString(previewURL), html.EscapeString(ctype))
|
|
extraBody = fmt.Sprintf(`<div class="preview"><video controls preload="metadata" src="%s"></video></div>`, html.EscapeString(previewURL))
|
|
}
|
|
|
|
page := fmt.Sprintf(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>%s</title>
|
|
<meta name="description" content="%s">
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:title" content="%s">
|
|
<meta property="og:description" content="%s">
|
|
<meta property="og:url" content="%s">
|
|
<meta name="twitter:card" content="%s">%s
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background:
|
|
radial-gradient(circle at top right, rgba(246, 92, 183, 0.16), transparent 32%%),
|
|
radial-gradient(circle at bottom left, rgba(94, 212, 167, 0.14), transparent 30%%),
|
|
#0b0d15;
|
|
color: #f3efe8;
|
|
font-family: Inter, system-ui, sans-serif;
|
|
}
|
|
.shell {
|
|
width: min(720px, 100%%);
|
|
border: 3px solid rgba(243, 239, 232, 0.92);
|
|
background: rgba(10, 14, 24, 0.92);
|
|
box-shadow: 12px 12px 0 rgba(0, 0, 0, 0.45);
|
|
}
|
|
.body {
|
|
padding: 28px;
|
|
display: grid;
|
|
gap: 20px;
|
|
}
|
|
.eyebrow {
|
|
font-size: 11px;
|
|
letter-spacing: 0.24em;
|
|
text-transform: uppercase;
|
|
color: rgba(243, 239, 232, 0.7);
|
|
}
|
|
h1 {
|
|
margin: 0;
|
|
font-size: clamp(2rem, 6vw, 3.4rem);
|
|
line-height: 0.92;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
color: rgba(243, 239, 232, 0.78);
|
|
line-height: 1.6;
|
|
}
|
|
.meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
.chip {
|
|
border: 2px solid rgba(243, 239, 232, 0.86);
|
|
padding: 8px 10px;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.16em;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
}
|
|
.button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 52px;
|
|
padding: 0 18px;
|
|
border: 3px solid rgba(243, 239, 232, 0.94);
|
|
background: #f65cb7;
|
|
color: #0b0d15;
|
|
font-weight: 800;
|
|
letter-spacing: 0.14em;
|
|
text-decoration: none;
|
|
text-transform: uppercase;
|
|
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.45);
|
|
}
|
|
.button.secondary {
|
|
background: transparent;
|
|
color: #f3efe8;
|
|
}
|
|
.preview {
|
|
border: 3px solid rgba(243, 239, 232, 0.92);
|
|
background: rgba(5, 7, 12, 0.92);
|
|
overflow: hidden;
|
|
}
|
|
.preview img, .preview video {
|
|
display: block;
|
|
width: 100%%;
|
|
max-height: 62vh;
|
|
object-fit: contain;
|
|
background: #05070c;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="shell">
|
|
<div class="body">
|
|
<div class="eyebrow">FileZ Share</div>
|
|
<div>
|
|
<h1>%s</h1>
|
|
<p>%s</p>
|
|
</div>
|
|
<div class="meta">
|
|
<span class="chip">%s</span>
|
|
<span class="chip">%s</span>
|
|
</div>
|
|
%s
|
|
<div class="actions">
|
|
<a class="button" href="%s">Open File</a>
|
|
<a class="button secondary" href="%s" download>Download</a>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
html.EscapeString(name),
|
|
html.EscapeString(description),
|
|
html.EscapeString(name),
|
|
html.EscapeString(description),
|
|
html.EscapeString(pageURL),
|
|
html.EscapeString(cardType),
|
|
extraMeta,
|
|
html.EscapeString(name),
|
|
html.EscapeString(description),
|
|
html.EscapeString(sharedKindLabel(meta, ctype)),
|
|
html.EscapeString(sharedExpiryLabel(record.ExpiresAt)),
|
|
extraBody,
|
|
html.EscapeString(downloadURL),
|
|
html.EscapeString(downloadURL),
|
|
)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
if _, err := io.WriteString(w, page); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
type shareRecord struct {
|
|
UserID int64
|
|
RelPath string
|
|
ExpiresAt time.Time
|
|
RevokedAt sql.NullTime
|
|
}
|
|
|
|
func (s *Server) lookupActiveShare(r *http.Request) (shareRecord, string, error) {
|
|
token := strings.TrimSpace(mux.Vars(r)["token"])
|
|
if token == "" {
|
|
return shareRecord{}, "", fmt.Errorf("missing token")
|
|
}
|
|
|
|
var record shareRecord
|
|
err := s.db.QueryRow(`SELECT user_id, rel_path, expires_at, revoked_at FROM share_links WHERE token_hash = ?`, hashToken(token)).
|
|
Scan(&record.UserID, &record.RelPath, &record.ExpiresAt, &record.RevokedAt)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return shareRecord{}, "", fmt.Errorf("share link not found")
|
|
}
|
|
return shareRecord{}, "", err
|
|
}
|
|
if record.RevokedAt.Valid || record.ExpiresAt.Before(time.Now()) {
|
|
return shareRecord{}, "", fmt.Errorf("share link expired")
|
|
}
|
|
return record, token, nil
|
|
}
|
|
|
|
func shareHTTPStatus(err error) int {
|
|
if err == nil {
|
|
return http.StatusOK
|
|
}
|
|
switch strings.TrimSpace(strings.ToLower(err.Error())) {
|
|
case "missing token":
|
|
return http.StatusBadRequest
|
|
case "share link expired", "share link download limit reached":
|
|
return http.StatusGone
|
|
case "share link not found":
|
|
return http.StatusNotFound
|
|
default:
|
|
return http.StatusInternalServerError
|
|
}
|
|
}
|
|
|
|
func writeSharedPageStatus(w http.ResponseWriter, code int, title, description string) {
|
|
title = strings.TrimSpace(title)
|
|
if title == "" {
|
|
title = "Share unavailable"
|
|
}
|
|
description = strings.TrimSpace(description)
|
|
if description == "" {
|
|
description = "This shared file could not be loaded."
|
|
}
|
|
|
|
page := fmt.Sprintf(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>%s</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background: #0b0d15;
|
|
color: #f3efe8;
|
|
font-family: Inter, system-ui, sans-serif;
|
|
}
|
|
.card {
|
|
width: min(560px, 100%%);
|
|
border: 3px solid rgba(243, 239, 232, 0.92);
|
|
background: rgba(10, 14, 24, 0.94);
|
|
padding: 28px;
|
|
box-shadow: 12px 12px 0 rgba(0, 0, 0, 0.45);
|
|
}
|
|
h1 { margin: 0 0 12px; font-size: clamp(2rem, 6vw, 3rem); line-height: 0.95; }
|
|
p { margin: 0; color: rgba(243, 239, 232, 0.78); line-height: 1.6; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<h1>%s</h1>
|
|
<p>%s</p>
|
|
</main>
|
|
</body>
|
|
</html>`,
|
|
html.EscapeString(title),
|
|
html.EscapeString(title),
|
|
html.EscapeString(description),
|
|
)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_, _ = io.WriteString(w, page)
|
|
}
|
|
|
|
func sharedFileDescription(meta FileMeta, expiresAt time.Time) string {
|
|
parts := []string{sharedKindLabel(meta, mime.TypeByExtension(strings.ToLower(filepath.Ext(meta.Name))))}
|
|
if !meta.IsDir && meta.Size > 0 {
|
|
parts = append(parts, humanSize(meta.Size))
|
|
}
|
|
parts = append(parts, "Expires "+expiresAt.UTC().Format("02 Jan 2006 15:04 UTC"))
|
|
return strings.Join(parts, " • ")
|
|
}
|
|
|
|
func sharedKindLabel(meta FileMeta, ctype string) string {
|
|
if meta.IsDir {
|
|
return "Folder"
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(ctype, "image/"):
|
|
return "Image"
|
|
case strings.HasPrefix(ctype, "video/"):
|
|
return "Video"
|
|
case strings.HasPrefix(ctype, "audio/"):
|
|
return "Audio"
|
|
default:
|
|
return "File"
|
|
}
|
|
}
|
|
|
|
func sharedExpiryLabel(expiresAt time.Time) string {
|
|
return "Expires " + expiresAt.UTC().Format("02 Jan 2006")
|
|
}
|
|
|
|
func humanSize(size int64) string {
|
|
if size < 1024 {
|
|
return fmt.Sprintf("%d B", size)
|
|
}
|
|
units := []string{"KB", "MB", "GB", "TB"}
|
|
value := float64(size)
|
|
unit := "B"
|
|
for _, next := range units {
|
|
value /= 1024
|
|
unit = next
|
|
if value < 1024 {
|
|
break
|
|
}
|
|
}
|
|
if value >= 10 || unit == "KB" {
|
|
return fmt.Sprintf("%.0f %s", value, unit)
|
|
}
|
|
return fmt.Sprintf("%.1f %s", value, unit)
|
|
}
|
|
|
|
func (s *Server) handleAdminMe(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"login": s.config.AdminLogin})
|
|
}
|
|
|
|
func (s *Server) handleAdminUsersList(w http.ResponseWriter, _ *http.Request) {
|
|
users, err := s.orm.listUsers()
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to load users")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
|
}
|
|
|
|
type adminCreateUserInput struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Theme string `json:"theme"`
|
|
ColorMode string `json:"colorMode"`
|
|
}
|
|
|
|
func (s *Server) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) {
|
|
var in adminCreateUserInput
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid payload")
|
|
return
|
|
}
|
|
|
|
in.Theme = normalizeTheme(in.Theme)
|
|
in.ColorMode = normalizeColorMode(in.ColorMode)
|
|
user, err := s.createUser(in.Username, in.Password, in.Theme, in.ColorMode)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "exists") {
|
|
writeErr(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, user)
|
|
log.Printf("admin.user.create user_id=%d username=%q", user.ID, user.Username)
|
|
}
|
|
|
|
func (s *Server) handleAdminUserDelete(w http.ResponseWriter, r *http.Request) {
|
|
idStr := mux.Vars(r)["id"]
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
writeErr(w, http.StatusBadRequest, "invalid user id")
|
|
return
|
|
}
|
|
|
|
if _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to delete user")
|
|
return
|
|
}
|
|
_, _ = s.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, id)
|
|
_, _ = s.db.Exec(`UPDATE share_links SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ?`, id)
|
|
_ = s.storage.Delete(id, "/")
|
|
s.purgePersistedSearchContentForUser(id)
|
|
s.purgePersistedPreviewThumbnailForUser(id)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
log.Printf("admin.user.delete user_id=%d", id)
|
|
}
|
|
|
|
func (s *Server) recoverMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if v := recover(); v != nil {
|
|
log.Printf("panic recovered path=%q err=%v", r.URL.Path, v)
|
|
writeErr(w, http.StatusInternalServerError, "internal server error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; connect-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
|
if r.TLS != nil {
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) bodyLimitMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/api/files/upload") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.config.MaxBodyBytes)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
ip := clientIP(r)
|
|
limit := s.config.RateLimitPerMin
|
|
if strings.Contains(r.URL.Path, "/auth/") || strings.Contains(r.URL.Path, "/admin/login") {
|
|
limit = s.config.AuthRateLimitPerMin
|
|
}
|
|
|
|
if !s.limiter.allow(ip+":"+r.URL.Path, limit, time.Now()) {
|
|
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) requestLogMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rec, r)
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
log.Printf(
|
|
"api.request method=%s path=%q status=%d duration_ms=%d ip=%q ua=%q",
|
|
r.Method,
|
|
r.URL.Path,
|
|
rec.status,
|
|
time.Since(start).Milliseconds(),
|
|
clientIP(r),
|
|
limitString(r.UserAgent(), 120),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *Server) createUser(username, password, theme, colorMode string) (User, error) {
|
|
username = strings.ToLower(strings.TrimSpace(username))
|
|
if len(username) < 3 || len(username) > 32 {
|
|
return User{}, fmt.Errorf("username must be 3-32 characters")
|
|
}
|
|
for _, ch := range username {
|
|
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '_' && ch != '-' && ch != '.' {
|
|
return User{}, fmt.Errorf("username can contain only a-z, 0-9, dot, dash, underscore")
|
|
}
|
|
}
|
|
if len(password) < 10 {
|
|
return User{}, fmt.Errorf("password must be at least 10 characters")
|
|
}
|
|
|
|
hash, err := hashPasswordArgon2ID(password)
|
|
if err != nil {
|
|
return User{}, fmt.Errorf("failed to hash password")
|
|
}
|
|
|
|
id, err := s.orm.createUser(username, hash, normalizeTheme(theme), normalizeColorMode(colorMode), "zip", nil)
|
|
if err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
|
return User{}, fmt.Errorf("account already exists")
|
|
}
|
|
return User{}, err
|
|
}
|
|
if err := s.storage.Mkdir(id, "/"); err != nil {
|
|
return User{}, fmt.Errorf("failed to provision user storage: %w", err)
|
|
}
|
|
|
|
return User{ID: id, Username: username, Theme: normalizeTheme(theme), ColorMode: normalizeColorMode(colorMode), Archive: "zip"}, nil
|
|
}
|
|
|
|
func (s *Server) findUserWithHash(username string) (User, string, error) {
|
|
return s.orm.findUserWithHashByEmail(username)
|
|
}
|
|
|
|
func (s *Server) findUser(id int64) (User, error) {
|
|
return s.orm.findUserByID(id)
|
|
}
|
|
|
|
func (s *Server) issueUserSession(w http.ResponseWriter, r *http.Request, userID int64) error {
|
|
now := time.Now()
|
|
claims := AccessClaims{
|
|
UserID: userID,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: strconv.FormatInt(userID, 10),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.config.AccessTTL)),
|
|
},
|
|
}
|
|
|
|
access, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.config.JWTSecret))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
refresh, err := randomToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := s.db.Exec(`INSERT INTO refresh_tokens(user_id, token_hash, expires_at, user_agent, ip) VALUES (?, ?, ?, ?, ?)`,
|
|
userID,
|
|
hashToken(refresh),
|
|
now.Add(s.config.RefreshTTL),
|
|
limitString(r.UserAgent(), 255),
|
|
limitString(clientIP(r), 64),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
setCookie(w, "access_token", access, int(s.config.AccessTTL.Seconds()), s.config.CookieSecure)
|
|
setCookie(w, "refresh_token", refresh, int(s.config.RefreshTTL.Seconds()), s.config.CookieSecure)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) consumeRefreshToken(token string) (int64, error) {
|
|
var id int64
|
|
var uid int64
|
|
var expiresAt time.Time
|
|
var revokedAt sql.NullTime
|
|
err := s.db.QueryRow(`SELECT id, user_id, expires_at, revoked_at FROM refresh_tokens WHERE token_hash = ?`, hashToken(token)).
|
|
Scan(&id, &uid, &expiresAt, &revokedAt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if revokedAt.Valid || expiresAt.Before(time.Now()) {
|
|
return 0, fmt.Errorf("refresh token expired or revoked")
|
|
}
|
|
|
|
if _, err := s.db.Exec(`UPDATE refresh_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?`, id); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return uid, nil
|
|
}
|
|
|
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("access_token")
|
|
if err != nil || cookie.Value == "" {
|
|
writeErr(w, http.StatusUnauthorized, "missing access token")
|
|
return
|
|
}
|
|
|
|
claims := &AccessClaims{}
|
|
tkn, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method")
|
|
}
|
|
return []byte(s.config.JWTSecret), nil
|
|
})
|
|
if err != nil || !tkn.Valid {
|
|
writeErr(w, http.StatusUnauthorized, "invalid access token")
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userIDKey, claims.UserID)))
|
|
})
|
|
}
|
|
|
|
func (s *Server) adminMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie("admin_token")
|
|
if err != nil || cookie.Value == "" {
|
|
writeErr(w, http.StatusUnauthorized, "missing admin token")
|
|
return
|
|
}
|
|
|
|
claims := &AdminClaims{}
|
|
tkn, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method")
|
|
}
|
|
return []byte(s.config.JWTSecret), nil
|
|
})
|
|
if err != nil || !tkn.Valid || claims.Role != "admin" || claims.Login != s.config.AdminLogin {
|
|
writeErr(w, http.StatusUnauthorized, "invalid admin session")
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
|
if origin != "" && s.config.CORSOrigin != "" && origin != s.config.CORSOrigin {
|
|
writeErr(w, http.StatusForbidden, "cors origin denied")
|
|
return
|
|
}
|
|
|
|
if s.config.CORSOrigin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", s.config.CORSOrigin)
|
|
w.Header().Set("Vary", "Origin")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS,HEAD")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) hostGuardMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.TrimSpace(s.config.AllowedHost) == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
host := strings.ToLower(hostOnly(r.Host))
|
|
if host == strings.ToLower(s.config.AllowedHost) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
writeErr(w, http.StatusForbidden, fmt.Sprintf("access denied: host must be %s", s.config.AllowedHost))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(fmt.Sprintf("<!doctype html><html><body><h1>Access denied</h1><p>This service is only available at <b>%s</b>.</p></body></html>", s.config.AllowedHost)))
|
|
})
|
|
}
|
|
|
|
type LocalStorage struct {
|
|
root string
|
|
}
|
|
|
|
func (l *LocalStorage) userRoot(userID int64) string {
|
|
return filepath.Join(l.root, strconv.FormatInt(userID, 10))
|
|
}
|
|
|
|
func (l *LocalStorage) fullPath(userID int64, rel string) (string, error) {
|
|
root := l.userRoot(userID)
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
clean := filepath.FromSlash(strings.TrimPrefix(normalizePath(rel), "/"))
|
|
full := filepath.Clean(filepath.Join(root, clean))
|
|
if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) {
|
|
return "", fmt.Errorf("invalid path")
|
|
}
|
|
return full, nil
|
|
}
|
|
|
|
func (l *LocalStorage) List(userID int64, rel string) ([]FileEntry, error) {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entries, err := os.ReadDir(full)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return []FileEntry{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]FileEntry, 0, len(entries))
|
|
for _, e := range entries {
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, FileEntry{
|
|
Name: e.Name(),
|
|
Path: path.Join(normalizePath(rel), e.Name()),
|
|
IsDir: e.IsDir(),
|
|
Size: info.Size(),
|
|
ModTime: info.ModTime(),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (l *LocalStorage) Mkdir(userID int64, rel string) error {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.MkdirAll(full, 0o755)
|
|
}
|
|
|
|
func (l *LocalStorage) Save(userID int64, rel string, src multipart.File) error {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return err
|
|
}
|
|
dst, err := os.Create(full)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dst.Close()
|
|
_, err = io.Copy(dst, src)
|
|
return err
|
|
}
|
|
|
|
func (l *LocalStorage) SaveBytes(userID int64, rel string, data []byte) error {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(full, data, 0o644)
|
|
}
|
|
|
|
func (l *LocalStorage) Delete(userID int64, rel string) error {
|
|
if normalizePath(rel) == "/" {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.RemoveAll(full)
|
|
}
|
|
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.RemoveAll(full)
|
|
}
|
|
|
|
func (l *LocalStorage) Stat(userID int64, rel string) (FileMeta, error) {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return FileMeta{}, err
|
|
}
|
|
st, err := os.Stat(full)
|
|
if err != nil {
|
|
return FileMeta{}, err
|
|
}
|
|
return FileMeta{Name: st.Name(), Size: st.Size(), ModTime: st.ModTime(), IsDir: st.IsDir()}, nil
|
|
}
|
|
|
|
func (l *LocalStorage) OpenReadSeeker(userID int64, rel string) (ReadSeekCloser, error) {
|
|
full, err := l.fullPath(userID, rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.Open(full)
|
|
}
|
|
|
|
func normalizePath(rel string) string {
|
|
clean := path.Clean("/" + strings.TrimSpace(rel))
|
|
if clean == "." {
|
|
return "/"
|
|
}
|
|
return clean
|
|
}
|
|
|
|
func normalizeUploadRelativePath(v string) (string, error) {
|
|
v = strings.ReplaceAll(strings.TrimSpace(v), "\\", "/")
|
|
v = strings.TrimPrefix(v, "/")
|
|
v = path.Clean(v)
|
|
if v == "." || v == "" {
|
|
return "", fmt.Errorf("invalid upload path")
|
|
}
|
|
if strings.HasPrefix(v, "../") || strings.Contains(v, "/../") || strings.HasPrefix(v, "/") {
|
|
return "", fmt.Errorf("invalid upload path")
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func normalizeTheme(v string) string {
|
|
v = strings.TrimSpace(strings.ToLower(v))
|
|
switch v {
|
|
case "dracula", "nord", "monokai", "solarized", "github":
|
|
return v
|
|
case "material", "glass", "desktop", "auto":
|
|
return "dracula"
|
|
default:
|
|
return "dracula"
|
|
}
|
|
}
|
|
|
|
func normalizeColorMode(v string) string {
|
|
v = strings.TrimSpace(strings.ToLower(v))
|
|
switch v {
|
|
case "light", "dark", "auto":
|
|
return v
|
|
default:
|
|
return "auto"
|
|
}
|
|
}
|
|
|
|
func normalizeArchiveFormat(v string) string {
|
|
v = strings.TrimSpace(strings.ToLower(v))
|
|
switch v {
|
|
case "zip", "rar", "tar.gz", "lz4":
|
|
return v
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeTag(v string) (string, bool) {
|
|
v = strings.ToLower(strings.TrimSpace(v))
|
|
if len(v) < 1 || len(v) > 24 {
|
|
return "", false
|
|
}
|
|
for _, ch := range v {
|
|
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '-' && ch != '_' {
|
|
return "", false
|
|
}
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
func isMarkdownExtension(ext string) bool {
|
|
ext = strings.ToLower(strings.TrimPrefix(ext, "."))
|
|
switch ext {
|
|
case "md", "markdown":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Server) fileTagsForPaths(uid int64, paths []string) (map[string][]string, error) {
|
|
out := make(map[string][]string, len(paths))
|
|
if len(paths) == 0 {
|
|
return out, nil
|
|
}
|
|
args := make([]any, 0, len(paths)+1)
|
|
args = append(args, uid)
|
|
pl := make([]string, len(paths))
|
|
for i, p := range paths {
|
|
norm := normalizePath(p)
|
|
pl[i] = "?"
|
|
args = append(args, norm)
|
|
}
|
|
q := `SELECT rel_path, tag FROM file_tags WHERE user_id = ? AND rel_path IN (` + strings.Join(pl, ",") + `) ORDER BY rel_path, tag`
|
|
rows, err := s.db.Query(q, args...)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var rel string
|
|
var tag string
|
|
if rows.Scan(&rel, &tag) == nil {
|
|
out[rel] = append(out[rel], tag)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func setCookie(w http.ResponseWriter, name, value string, maxAge int, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: value,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: maxAge,
|
|
})
|
|
}
|
|
|
|
func clearCookie(w http.ResponseWriter, name string, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: secure,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
|
|
func randomToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func hashToken(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return base64.RawURLEncoding.EncodeToString(h[:])
|
|
}
|
|
|
|
func subtleConstantTimeEq(a, b string) int {
|
|
if len(a) != len(b) {
|
|
return 0
|
|
}
|
|
var out byte
|
|
for i := 0; i < len(a); i++ {
|
|
out |= a[i] ^ b[i]
|
|
}
|
|
if out == 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func maybeRunHashCommand() bool {
|
|
if len(os.Args) < 2 {
|
|
return false
|
|
}
|
|
if os.Args[1] != "hash-admin" {
|
|
return false
|
|
}
|
|
if len(os.Args) < 3 {
|
|
fmt.Println("usage: go run . hash-admin <password>")
|
|
os.Exit(1)
|
|
}
|
|
h, err := hashPasswordArgon2ID(os.Args[2])
|
|
if err != nil {
|
|
fmt.Println("failed to hash password:", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Println(h)
|
|
return true
|
|
}
|
|
|
|
func hashPasswordArgon2ID(password string) (string, error) {
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
timeCost := uint32(3)
|
|
memoryCost := uint32(64 * 1024)
|
|
threads := uint8(2)
|
|
keyLen := uint32(32)
|
|
derived := argon2.IDKey([]byte(password), salt, timeCost, memoryCost, threads, keyLen)
|
|
|
|
saltB64 := base64.RawStdEncoding.EncodeToString(salt)
|
|
hashB64 := base64.RawStdEncoding.EncodeToString(derived)
|
|
encoded := fmt.Sprintf("argon2id:v=19,m=%d,t=%d,p=%d:%s:%s", memoryCost, timeCost, threads, saltB64, hashB64)
|
|
return encoded, nil
|
|
}
|
|
|
|
func verifyPasswordHash(storedHash, password string) bool {
|
|
if strings.HasPrefix(storedHash, "argon2id:") {
|
|
parts := strings.Split(storedHash, ":")
|
|
if len(parts) != 4 {
|
|
return false
|
|
}
|
|
var mem, timeCost uint32
|
|
var threads uint8
|
|
if _, err := fmt.Sscanf(parts[1], "v=19,m=%d,t=%d,p=%d", &mem, &timeCost, &threads); err != nil {
|
|
return false
|
|
}
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
expected, err := base64.RawStdEncoding.DecodeString(parts[3])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
actual := argon2.IDKey([]byte(password), salt, timeCost, mem, threads, uint32(len(expected)))
|
|
return subtle.ConstantTimeCompare(expected, actual) == 1
|
|
}
|
|
|
|
if strings.HasPrefix(storedHash, "argon2id$") {
|
|
parts := strings.Split(storedHash, "$")
|
|
if len(parts) == 6 {
|
|
var mem, timeCost uint32
|
|
var threads uint8
|
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &timeCost, &threads); err == nil {
|
|
salt, errSalt := base64.RawStdEncoding.DecodeString(parts[4])
|
|
expected, errExpected := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if errSalt == nil && errExpected == nil {
|
|
actual := argon2.IDKey([]byte(password), salt, timeCost, mem, threads, uint32(len(expected)))
|
|
return subtle.ConstantTimeCompare(expected, actual) == 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(storedHash, "sha256:") {
|
|
expected := strings.TrimPrefix(storedHash, "sha256:")
|
|
sum := sha256.Sum256([]byte(password))
|
|
actual := hex.EncodeToString(sum[:])
|
|
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
|
|
}
|
|
|
|
bcryptHash := storedHash
|
|
if strings.HasPrefix(storedHash, "bcrypt:") {
|
|
bcryptHash = strings.TrimPrefix(storedHash, "bcrypt:")
|
|
}
|
|
if strings.HasPrefix(bcryptHash, "$2a$") || strings.HasPrefix(bcryptHash, "$2b$") || strings.HasPrefix(bcryptHash, "$2y$") {
|
|
return bcrypt.CompareHashAndPassword([]byte(bcryptHash), []byte(password)) == nil
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func verifyAdminPasswordHash(storedHash, password string) bool {
|
|
return verifyPasswordHash(storedHash, password)
|
|
}
|
|
|
|
func userIDFromContext(ctx context.Context) int64 {
|
|
v, _ := ctx.Value(userIDKey).(int64)
|
|
return v
|
|
}
|
|
|
|
func schemeOf(r *http.Request) string {
|
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
return proto
|
|
}
|
|
if r.TLS != nil {
|
|
return "https"
|
|
}
|
|
return "http"
|
|
}
|
|
|
|
func hostOnly(hostport string) string {
|
|
h := strings.TrimSpace(hostport)
|
|
if h == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(h, ":") {
|
|
if host, _, err := net.SplitHostPort(h); err == nil {
|
|
return host
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
for _, h := range []string{"CF-Connecting-IP", "X-Forwarded-For", "X-Real-IP"} {
|
|
v := strings.TrimSpace(r.Header.Get(h))
|
|
if v != "" {
|
|
if strings.Contains(v, ",") {
|
|
return strings.TrimSpace(strings.Split(v, ",")[0])
|
|
}
|
|
return v
|
|
}
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return host
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func limitString(s string, max int) string {
|
|
if len(s) <= max {
|
|
return s
|
|
}
|
|
return s[:max]
|
|
}
|