feat: implement ufile sharing service

This commit is contained in:
loveuer
2026-09-09 20:53:39 -07:00
commit d3016b94db
19 changed files with 4022 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
/ufile
/data/
/frontend/node_modules/
/frontend/dist/
*.db
*.db-shm
*.db-wal
+13
View File
@@ -0,0 +1,13 @@
.PHONY: build frontend test clean
build: frontend
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile .
frontend:
cd frontend && npm ci && npm run build
test: frontend
go test ./...
clean:
rm -f ufile
+35
View File
@@ -0,0 +1,35 @@
# ufile
一个单二进制运行的轻量文件快传服务。支持多用户和三级权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。
## 构建
需要 Go 1.24+ 与 Node.js 20+:
```bash
./build.sh
```
也可以运行 `make build`。构建脚本会先安装并编译前端,再通过 `go:embed` 将产物写入最终的 `ufile` 二进制;SQLite 使用纯 Go 驱动,不依赖 CGO。
## 运行
```bash
./ufile --data ./data --address 0.0.0.0:6616
```
参数:
- `--data`:文件目录,默认 `./data`
- `--db`:SQLite 文件路径;留空时为 `<data>/data.db`
- `--address`:监听地址,默认 `0.0.0.0:6616`
首次使用空数据库启动时,终端会输出 `admin` 的随机密码。该账号第一次登录后必须立即修改密码。
## 分享链接
- 下载:`/uf/<file_hash>`
- 预览:`/uf/<file_hash>?preview=true`
- 有取件码时追加:`code=<6位取件码>`
预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。
+678
View File
@@ -0,0 +1,678 @@
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const sessionCookie = "ufile_session"
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
type App struct {
db *gorm.DB
filesDir string
web fs.FS
uploadMu sync.Mutex
downloadMu sync.Mutex
now func() time.Time
}
func newApp(dbPath, dataDir string, web fs.FS) (*App, string, error) {
filesDir := filepath.Join(dataDir, "files")
if err := os.MkdirAll(filesDir, 0o750); err != nil {
return nil, "", fmt.Errorf("create data directory: %w", err)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return nil, "", fmt.Errorf("open database: %w", err)
}
if err := db.AutoMigrate(&User{}, &Session{}, &SharedFile{}); err != nil {
return nil, "", fmt.Errorf("migrate database: %w", err)
}
app := &App{db: db, filesDir: filesDir, web: web, now: time.Now}
password, err := app.ensureAdmin()
return app, password, err
}
func (a *App) ensureAdmin() (string, error) {
var count int64
if err := a.db.Model(&User{}).Count(&count).Error; err != nil {
return "", err
}
if count != 0 {
return "", nil
}
password, err := randomToken(18)
if err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
admin := User{ID: 1, Username: "admin", PasswordHash: string(hash), CanManageUsers: true, CanUpload: true, CanAdvancedUpload: true, MustChangePassword: true}
if err := a.db.Create(&admin).Error; err != nil {
return "", err
}
return password, nil
}
func (a *App) router() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.POST("/api/auth/login", a.login)
r.POST("/api/auth/logout", a.auth(false), a.logout)
r.GET("/api/auth/me", a.auth(false), a.me)
r.PUT("/api/auth/password", a.auth(false), a.changePassword)
r.POST("/api/files", a.auth(true), a.uploadFile)
r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers)
r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser)
r.PUT("/api/users/:id", a.auth(true), a.manageUsers, a.updateUser)
r.POST("/api/users/:id/reset-password", a.auth(true), a.manageUsers, a.resetPassword)
r.DELETE("/api/users/:id", a.auth(true), a.manageUsers, a.deleteUser)
r.GET("/uf/:hash", a.downloadFile)
r.NoRoute(a.serveSPA)
return r
}
func (a *App) auth(requireChanged bool) gin.HandlerFunc {
return func(c *gin.Context) {
token, err := c.Cookie(sessionCookie)
if err != nil || token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
var session Session
if err := a.db.Where("token_hash = ? AND expires_at > ?", tokenHash(token), a.now()).First(&session).Error; err != nil {
clearSessionCookie(c)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已过期"})
return
}
var user User
if err := a.db.First(&user, session.UserID).Error; err != nil {
clearSessionCookie(c)
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
return
}
if requireChanged && user.MustChangePassword {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "请先修改初始密码", "code": "PASSWORD_CHANGE_REQUIRED"})
return
}
c.Set("user", &user)
c.Set("session", &session)
c.Next()
}
}
func currentUser(c *gin.Context) *User { return c.MustGet("user").(*User) }
func (a *App) manageUsers(c *gin.Context) {
if !currentUser(c).CanManageUsers {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "没有用户管理权限"})
return
}
c.Next()
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (a *App) login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式不正确"})
return
}
var user User
if err := a.db.Where("username = ?", req.Username).First(&user).Error; err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
token, err := randomToken(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败"})
return
}
session := Session{TokenHash: tokenHash(token), UserID: user.ID, ExpiresAt: a.now().Add(30 * 24 * time.Hour)}
if err := a.db.Create(&session).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败"})
return
}
setSessionCookie(c, token, session.ExpiresAt)
c.JSON(http.StatusOK, user)
}
func (a *App) logout(c *gin.Context) {
session := c.MustGet("session").(*Session)
a.db.Delete(session)
clearSessionCookie(c)
c.Status(http.StatusNoContent)
}
func (a *App) me(c *gin.Context) { c.JSON(http.StatusOK, currentUser(c)) }
func (a *App) changePassword(c *gin.Context) {
var req struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.NewPassword) < 8 || len(req.NewPassword) > 128 {
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码长度需要为 8 到 128 位"})
return
}
user := currentUser(c)
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)) != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码错误"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"})
return
}
if err := a.db.Model(user).Updates(map[string]any{"password_hash": string(hash), "must_change_password": false}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "修改密码失败"})
return
}
user.PasswordHash = string(hash)
user.MustChangePassword = false
c.JSON(http.StatusOK, user)
}
func setSessionCookie(c *gin.Context, token string, expires time.Time) {
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
func clearSessionCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func randomToken(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func randomFileHash() (string, error) {
const alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"
b := make([]byte, 8)
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
return "", err
}
for i := range b {
b[i] = alphabet[int(raw[i])%len(alphabet)]
}
return string(b), nil
}
func parseRetention(value string, now time.Time) (*time.Time, bool) {
var duration time.Duration
switch value {
case "1h":
duration = time.Hour
case "8h":
duration = 8 * time.Hour
case "1d":
duration = 24 * time.Hour
case "3d":
duration = 72 * time.Hour
case "forever":
return nil, true
default:
return nil, false
}
t := now.Add(duration)
return &t, true
}
func parseDownloads(value string) (*int, bool) {
if value == "unlimited" {
return nil, true
}
n, err := strconv.Atoi(value)
if err != nil || n < 1 || n > 99 {
return nil, false
}
return &n, true
}
func (a *App) uploadFile(c *gin.Context) {
user := currentUser(c)
if !user.CanUpload && !user.CanAdvancedUpload {
c.JSON(http.StatusForbidden, gin.H{"error": "没有上传权限"})
return
}
expiresAt, ok := parseRetention(c.PostForm("retention"), a.now())
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的保留时间"})
return
}
maxDownloads, ok := parseDownloads(c.PostForm("downloads"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "下载次数需要为 1 到 99"})
return
}
if (expiresAt == nil || maxDownloads == nil) && !user.CanAdvancedUpload {
c.JSON(http.StatusForbidden, gin.H{"error": "永久保留和无限下载需要高级上传权限"})
return
}
pickupCode := c.PostForm("pickupCode")
if pickupCode != "" && !pickupCodePattern.MatchString(pickupCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "取件码必须是 6 位数字"})
return
}
header, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
return
}
a.uploadMu.Lock()
defer a.uploadMu.Unlock()
if !user.CanAdvancedUpload {
var count int64
if err := a.db.Model(&SharedFile{}).Where("owner_id = ? AND (expires_at IS NULL OR expires_at > ?)", user.ID, a.now()).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "检查上传限额失败"})
return
}
if count >= 10 {
c.JSON(http.StatusForbidden, gin.H{"error": "普通上传用户最多保留 10 个有效文件"})
return
}
}
var codeHash string
if pickupCode != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(pickupCode), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "处理取件码失败"})
return
}
codeHash = string(hash)
}
file := SharedFile{OriginalName: filepath.Base(header.Filename), MimeType: header.Header.Get("Content-Type"), OwnerID: user.ID, ExpiresAt: expiresAt, MaxDownloads: maxDownloads, PickupCodeHash: codeHash}
if file.MimeType == "" {
file.MimeType = "application/octet-stream"
}
for attempts := 0; attempts < 10; attempts++ {
file.ID = 0
file.Hash, err = randomFileHash()
if err != nil {
break
}
err = a.db.Create(&file).Error
if err == nil {
break
}
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件记录失败"})
return
}
src, err := header.Open()
if err != nil {
a.db.Delete(&file)
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败"})
return
}
defer src.Close()
temp, err := os.CreateTemp(a.filesDir, ".upload-*")
if err != nil {
a.db.Delete(&file)
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
tempName := temp.Name()
defer os.Remove(tempName)
size, copyErr := io.Copy(temp, src)
closeErr := temp.Close()
finalPath := filepath.Join(a.filesDir, file.Hash)
if copyErr != nil || closeErr != nil || os.Rename(tempName, finalPath) != nil {
a.db.Delete(&file)
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
file.Size = size
file.Ready = true
if err := a.db.Model(&file).Updates(map[string]any{"size": size, "ready": true}).Error; err != nil {
os.Remove(finalPath)
a.db.Delete(&file)
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
return
}
c.JSON(http.StatusCreated, presentFile(file))
}
func (a *App) downloadFile(c *gin.Context) {
var file SharedFile
if err := a.db.Where("hash = ? AND ready = ?", c.Param("hash"), true).First(&file).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) {
a.removeFile(file)
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
if file.PickupCodeHash != "" && bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(c.Query("code"))) != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "取件码错误"})
return
}
handle, err := os.Open(filepath.Join(a.filesDir, file.Hash))
if err != nil {
a.db.Delete(&file)
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
defer handle.Close()
removeAfter := false
if file.MaxDownloads != nil {
allowed, exhausted, err := a.consumeDownload(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件失败"})
return
}
if !allowed {
handle.Close()
a.removeFile(file)
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
removeAfter = exhausted
}
if removeAfter {
defer os.Remove(filepath.Join(a.filesDir, file.Hash))
}
c.Header("Content-Type", file.MimeType)
c.Header("Content-Length", strconv.FormatInt(file.Size, 10))
if c.Query("preview") != "true" {
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": file.OriginalName}))
} else {
c.Header("Content-Disposition", "inline")
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'")
}
c.Header("X-Content-Type-Options", "nosniff")
c.Status(http.StatusOK)
_, _ = io.Copy(c.Writer, handle)
}
func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) {
a.downloadMu.Lock()
defer a.downloadMu.Unlock()
result := a.db.Model(&SharedFile{}).Where("id = ? AND download_count < ?", file.ID, *file.MaxDownloads).UpdateColumn("download_count", gorm.Expr("download_count + 1"))
if result.Error != nil {
return false, false, result.Error
}
if result.RowsAffected == 0 {
return false, false, nil
}
var latest SharedFile
if err := a.db.First(&latest, file.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, false, nil
}
return false, false, err
}
if latest.DownloadCount < *file.MaxDownloads {
return true, false, nil
}
if err := a.db.Where("id = ? AND download_count >= ?", file.ID, *file.MaxDownloads).Delete(&SharedFile{}).Error; err != nil {
return false, false, err
}
return true, true, nil
}
func (a *App) removeFile(file SharedFile) {
a.db.Delete(&file)
_ = os.Remove(filepath.Join(a.filesDir, file.Hash))
}
func (a *App) cleanup() {
var files []SharedFile
now := a.now()
a.db.Where("(expires_at IS NOT NULL AND expires_at <= ?) OR (ready = ? AND created_at < ?)", now, false, now.Add(-time.Hour)).Find(&files)
for _, file := range files {
a.removeFile(file)
}
a.db.Where("expires_at <= ?", now).Delete(&Session{})
a.cleanupStorageOrphans(now)
}
func (a *App) cleanupStorageOrphans(now time.Time) {
entries, err := os.ReadDir(a.filesDir)
if err != nil {
return
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if strings.HasPrefix(entry.Name(), ".upload-") {
if info, err := entry.Info(); err == nil && info.ModTime().Before(now.Add(-time.Hour)) {
_ = os.Remove(filepath.Join(a.filesDir, entry.Name()))
}
continue
}
if len(entry.Name()) != 8 {
continue
}
var count int64
if err := a.db.Model(&SharedFile{}).Where("hash = ?", entry.Name()).Count(&count).Error; err == nil && count == 0 {
_ = os.Remove(filepath.Join(a.filesDir, entry.Name()))
}
}
}
func (a *App) runCleanup(ctx context.Context) {
a.cleanup()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.cleanup()
case <-ctx.Done():
return
}
}
}
func (a *App) serveSPA(c *gin.Context) {
if a.web == nil || strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
path := strings.TrimPrefix(filepath.Clean(c.Request.URL.Path), "/")
if path != "." {
if data, err := fs.ReadFile(a.web, path); err == nil {
if kind := mime.TypeByExtension(filepath.Ext(path)); kind != "" {
c.Header("Content-Type", kind)
}
c.Data(http.StatusOK, c.Writer.Header().Get("Content-Type"), data)
return
}
}
data, err := fs.ReadFile(a.web, "index.html")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"})
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}
func parseUserID(c *gin.Context) (uint, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
return 0, false
}
return uint(id), true
}
func (a *App) listUsers(c *gin.Context) {
var users []User
if err := a.db.Order("id").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取用户失败"})
return
}
c.JSON(http.StatusOK, users)
}
func (a *App) createUser(c *gin.Context) {
var req struct {
Username string `json:"username"`
CanManageUsers bool `json:"canManageUsers"`
CanUpload bool `json:"canUpload"`
CanAdvancedUpload bool `json:"canAdvancedUpload"`
}
if err := c.ShouldBindJSON(&req); err != nil || !usernamePattern.MatchString(req.Username) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"})
return
}
password, err := randomToken(12)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
user := User{Username: req.Username, PasswordHash: string(hash), CanManageUsers: req.CanManageUsers, CanUpload: req.CanUpload, CanAdvancedUpload: req.CanAdvancedUpload, MustChangePassword: true}
if err := a.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE") {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
}
return
}
c.JSON(http.StatusCreated, gin.H{"user": user, "temporaryPassword": password})
}
func (a *App) updateUser(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
var req struct {
Username string `json:"username"`
CanManageUsers bool `json:"canManageUsers"`
CanUpload bool `json:"canUpload"`
CanAdvancedUpload bool `json:"canAdvancedUpload"`
}
if err := c.ShouldBindJSON(&req); err != nil || !usernamePattern.MatchString(req.Username) {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"})
return
}
var user User
if err := a.db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
return
}
a.db.First(&user, id)
c.JSON(http.StatusOK, user)
}
func (a *App) resetPassword(c *gin.Context) {
id, ok := parseUserID(c)
if !ok {
return
}
var user User
if err := a.db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
password, err := randomToken(12)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&user).Updates(map[string]any{"password_hash": string(hash), "must_change_password": true}).Error; err != nil {
return err
}
return tx.Where("user_id = ?", id).Delete(&Session{}).Error
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置密码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"temporaryPassword": password})
}
func (a *App) deleteUser(c *gin.Context) {
a.uploadMu.Lock()
defer a.uploadMu.Unlock()
id, ok := parseUserID(c)
if !ok {
return
}
if id == 1 {
c.JSON(http.StatusForbidden, gin.H{"error": "ID 为 1 的用户不能删除"})
return
}
var user User
if err := a.db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
var files []SharedFile
if err := a.db.Where("owner_id = ?", id).Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
return
}
err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", id).Delete(&Session{}).Error; err != nil {
return err
}
if err := tx.Where("owner_id = ?", id).Delete(&SharedFile{}).Error; err != nil {
return err
}
return tx.Delete(&user).Error
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
return
}
for _, file := range files {
_ = os.Remove(filepath.Join(a.filesDir, file.Hash))
}
c.Status(http.StatusNoContent)
}
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"golang.org/x/crypto/bcrypt"
)
func testApp(t *testing.T) (*App, string) {
t.Helper()
dir := t.TempDir()
web := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("test")}}
app, password, err := newApp(filepath.Join(dir, "test.db"), dir, web)
if err != nil {
t.Fatal(err)
}
return app, password
}
func request(t *testing.T, router http.Handler, method, path string, body io.Reader, contentType string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, body)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if cookie != nil {
req.AddCookie(cookie)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
return recorder
}
func loginAs(t *testing.T, router http.Handler, username, password string) *http.Cookie {
t.Helper()
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
response := request(t, router, http.MethodPost, "/api/auth/login", bytes.NewReader(body), "application/json", nil)
if response.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", response.Code, response.Body.String())
}
for _, cookie := range response.Result().Cookies() {
if cookie.Name == sessionCookie {
return cookie
}
}
t.Fatal("session cookie was not set")
return nil
}
func uploadBody(t *testing.T, fields map[string]string, name string, content []byte) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
t.Fatal(err)
}
}
part, err := writer.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(content); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return body, writer.FormDataContentType()
}
func TestBootstrapAdminAndForcePasswordChange(t *testing.T) {
app, password := testApp(t)
if password == "" {
t.Fatal("initial password was not generated")
}
var admin User
if err := app.db.First(&admin, 1).Error; err != nil {
t.Fatal(err)
}
if admin.Username != "admin" || !admin.MustChangePassword || !admin.CanManageUsers || !admin.CanAdvancedUpload {
t.Fatalf("unexpected admin: %+v", admin)
}
if bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) != nil {
t.Fatal("generated password does not match")
}
router := app.router()
cookie := loginAs(t, router, "admin", password)
blocked := request(t, router, http.MethodPost, "/api/files", nil, "", cookie)
if blocked.Code != http.StatusForbidden || !strings.Contains(blocked.Body.String(), "PASSWORD_CHANGE_REQUIRED") {
t.Fatalf("forced password status = %d, body = %s", blocked.Code, blocked.Body.String())
}
body, _ := json.Marshal(map[string]string{"currentPassword": password, "newPassword": "new-password-123"})
changed := request(t, router, http.MethodPut, "/api/auth/password", bytes.NewReader(body), "application/json", cookie)
if changed.Code != http.StatusOK {
t.Fatalf("change password status = %d, body = %s", changed.Code, changed.Body.String())
}
if err := app.db.First(&admin, 1).Error; err != nil || admin.MustChangePassword {
t.Fatal("password change flag was not cleared")
}
}
func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) {
app, _ := testApp(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
user := User{Username: "sender", PasswordHash: string(hash), CanAdvancedUpload: true}
if err := app.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
router := app.router()
cookie := loginAs(t, router, user.Username, "password-123")
body, contentType := uploadBody(t, map[string]string{"retention": "1h", "downloads": "1", "pickupCode": "123456"}, "hello.txt", []byte("hello"))
uploaded := request(t, router, http.MethodPost, "/api/files", body, contentType, cookie)
if uploaded.Code != http.StatusCreated {
t.Fatalf("upload status = %d, body = %s", uploaded.Code, uploaded.Body.String())
}
var file fileResponse
if err := json.Unmarshal(uploaded.Body.Bytes(), &file); err != nil {
t.Fatal(err)
}
denied := request(t, router, http.MethodGet, "/uf/"+file.Hash, nil, "", nil)
if denied.Code != http.StatusForbidden {
t.Fatalf("missing code status = %d", denied.Code)
}
preview := request(t, router, http.MethodGet, "/uf/"+file.Hash+"?preview=true&code=123456", nil, "", nil)
if preview.Code != http.StatusOK || preview.Body.String() != "hello" {
t.Fatalf("preview status = %d, body = %s", preview.Code, preview.Body.String())
}
if disposition := preview.Header().Get("Content-Disposition"); disposition != "inline" {
t.Fatalf("disposition = %q", disposition)
}
var count int64
app.db.Model(&SharedFile{}).Where("hash = ?", file.Hash).Count(&count)
if count != 0 {
t.Fatal("file record remains after final access")
}
if _, err := os.Stat(filepath.Join(app.filesDir, file.Hash)); !os.IsNotExist(err) {
t.Fatal("stored file remains after final access")
}
again := request(t, router, http.MethodGet, "/uf/"+file.Hash+"?code=123456", nil, "", nil)
if again.Code != http.StatusNotFound {
t.Fatalf("second access status = %d", again.Code)
}
}
func TestFiniteDownloadCountDeletesAfterMoreThanOneAccess(t *testing.T) {
app, _ := testApp(t)
max := 3
file := SharedFile{Hash: "three123", OriginalName: "three.txt", MimeType: "text/plain", OwnerID: 1, MaxDownloads: &max, Ready: true, Size: 1}
if err := app.db.Create(&file).Error; err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(app.filesDir, file.Hash), []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
router := app.router()
for i := 0; i < max; i++ {
response := request(t, router, http.MethodGet, "/uf/"+file.Hash, nil, "", nil)
if response.Code != http.StatusOK {
t.Fatalf("access %d status = %d", i+1, response.Code)
}
}
response := request(t, router, http.MethodGet, "/uf/"+file.Hash, nil, "", nil)
if response.Code != http.StatusNotFound {
t.Fatalf("access after limit status = %d", response.Code)
}
if _, err := os.Stat(filepath.Join(app.filesDir, file.Hash)); !os.IsNotExist(err) {
t.Fatal("file remains after third access")
}
}
func TestConcurrentAccessCannotExceedDownloadLimit(t *testing.T) {
app, _ := testApp(t)
max := 5
file := SharedFile{Hash: "parallel", OriginalName: "parallel.txt", MimeType: "text/plain", OwnerID: 1, MaxDownloads: &max, Ready: true, Size: 1}
if err := app.db.Create(&file).Error; err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(app.filesDir, file.Hash), []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
router := app.router()
statuses := make(chan int, 20)
var group sync.WaitGroup
for i := 0; i < 20; i++ {
group.Add(1)
go func() {
defer group.Done()
statuses <- request(t, router, http.MethodGet, "/uf/"+file.Hash, nil, "", nil).Code
}()
}
group.Wait()
close(statuses)
successes := 0
for status := range statuses {
if status == http.StatusOK {
successes++
}
}
if successes != max {
t.Fatalf("successful accesses = %d, want %d", successes, max)
}
}
func TestCleanupRemovesExpiredFileFromDatabaseAndDisk(t *testing.T) {
app, _ := testApp(t)
expired := time.Now().Add(-time.Minute)
max := 10
file := SharedFile{Hash: "expired1", OriginalName: "expired.txt", MimeType: "text/plain", OwnerID: 1, ExpiresAt: &expired, MaxDownloads: &max, Ready: true, Size: 1}
if err := app.db.Create(&file).Error; err != nil {
t.Fatal(err)
}
path := filepath.Join(app.filesDir, file.Hash)
if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
app.cleanup()
var count int64
app.db.Model(&SharedFile{}).Where("id = ?", file.ID).Count(&count)
if count != 0 {
t.Fatal("expired database record remains")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("expired file remains on disk")
}
}
func TestBasicUploadLimitAndAdvancedInheritance(t *testing.T) {
app, _ := testApp(t)
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
basic := User{Username: "basic", PasswordHash: string(passwordHash), CanUpload: true}
if err := app.db.Create(&basic).Error; err != nil {
t.Fatal(err)
}
expires := time.Now().Add(time.Hour)
max := 1
for i := 0; i < 10; i++ {
file := SharedFile{Hash: "A" + strings.Repeat("0", 6) + string(rune('0'+i)), OriginalName: "x", MimeType: "text/plain", OwnerID: basic.ID, ExpiresAt: &expires, MaxDownloads: &max, Ready: true}
if err := app.db.Create(&file).Error; err != nil {
t.Fatal(err)
}
}
router := app.router()
cookie := loginAs(t, router, basic.Username, "password-123")
body, contentType := uploadBody(t, map[string]string{"retention": "1h", "downloads": "1"}, "blocked.txt", []byte("x"))
response := request(t, router, http.MethodPost, "/api/files", body, contentType, cookie)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "10") {
t.Fatalf("limit status = %d, body = %s", response.Code, response.Body.String())
}
advanced := User{Username: "advanced", PasswordHash: string(passwordHash), CanAdvancedUpload: true}
if err := app.db.Create(&advanced).Error; err != nil {
t.Fatal(err)
}
cookie = loginAs(t, router, advanced.Username, "password-123")
body, contentType = uploadBody(t, map[string]string{"retention": "forever", "downloads": "unlimited"}, "advanced.txt", []byte("x"))
response = request(t, router, http.MethodPost, "/api/files", body, contentType, cookie)
if response.Code != http.StatusCreated {
t.Fatalf("advanced upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDeleteUserRemovesOwnedFilesAndProtectsIDOne(t *testing.T) {
app, password := testApp(t)
var admin User
app.db.First(&admin, 1)
app.db.Model(&admin).Update("must_change_password", false)
user := User{Username: "doomed", PasswordHash: admin.PasswordHash}
if err := app.db.Create(&user).Error; err != nil {
t.Fatal(err)
}
file := SharedFile{Hash: "deadfile", OriginalName: "x", MimeType: "text/plain", OwnerID: user.ID, Ready: true}
if err := app.db.Create(&file).Error; err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(app.filesDir, file.Hash), []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
router := app.router()
cookie := loginAs(t, router, "admin", password)
protected := request(t, router, http.MethodDelete, "/api/users/1", nil, "", cookie)
if protected.Code != http.StatusForbidden {
t.Fatalf("delete admin status = %d", protected.Code)
}
deleted := request(t, router, http.MethodDelete, "/api/users/"+jsonNumber(user.ID), nil, "", cookie)
if deleted.Code != http.StatusNoContent {
t.Fatalf("delete user status = %d, body = %s", deleted.Code, deleted.Body.String())
}
if _, err := os.Stat(filepath.Join(app.filesDir, file.Hash)); !os.IsNotExist(err) {
t.Fatal("owned file remains")
}
}
func jsonNumber(id uint) string {
data, _ := json.Marshal(id)
return string(data)
}
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/frontend"
npm ci
npm run build
cd ..
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile .
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f7f2" />
<title>ufile · 文件共享中心</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2363
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ufile-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.13",
"@vitejs/plugin-react": "^5.0.2",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.13",
"vite": "^7.1.4",
"zustand": "^5.0.8"
},
"devDependencies": {}
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState } from 'react'
import {
ArrowDownToLine, Check, Copy, Eye, FileArchive, FileUp,
KeyRound, LoaderCircle, LogOut, ShieldCheck,
UploadCloud, Users, X,
} from 'lucide-react'
import { api, json } from './api'
import { useAppStore } from './store'
const formatBytes = (bytes) => {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let unit = 0
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`
}
const formatDate = (value) => value ? new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).format(new Date(value)) : '永久'
function Toast({ message, onClose }) {
useEffect(() => { const timer = setTimeout(onClose, 3600); return () => clearTimeout(timer) }, [message, onClose])
return <div className="fixed bottom-5 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-xl bg-ink px-4 py-3 text-sm text-white shadow-xl"><Check size={16} />{message}</div>
}
function Login() {
const login = useAppStore((s) => s.login)
const [form, setForm] = useState({ username: '', password: '' })
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [pickup, setPickup] = useState({ hash: '', code: '' })
const retrieve = (preview = false) => {
const hash = pickup.hash.trim()
if (!hash) return
const query = new URLSearchParams()
if (pickup.code) query.set('code', pickup.code)
if (preview) query.set('preview', 'true')
window.open(`/uf/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`, '_blank')
}
const submit = async (event) => {
event.preventDefault(); setBusy(true); setError('')
try { await login(form.username, form.password) }
catch (e) { setError(e.message) }
finally { setBusy(false) }
}
return <main className="noise min-h-screen px-5 py-8 lg:grid lg:grid-cols-[1.08fr_.92fr] lg:items-center lg:px-[8vw]">
<section className="mx-auto max-w-2xl py-8 lg:mx-0">
<div className="mb-12 flex items-center gap-3"><div className="grid size-11 place-items-center rounded-2xl bg-leaf text-white"><FileArchive size={23} /></div><div><p className="text-xl font-semibold tracking-tight">ufile</p><p className="text-xs text-ink/45">轻量文件共享中心</p></div></div>
<p className="mb-4 text-sm font-medium uppercase tracking-[.24em] text-leaf">Send less. Share better.</p>
<h1 className="max-w-xl text-5xl font-semibold leading-[1.08] tracking-[-.045em] sm:text-6xl">把文件交给链接,<br />把空间留给重要的事。</h1>
<p className="mt-6 max-w-lg text-base leading-7 text-ink/55">限时保存、下载次数与取件码,让每一次分享都清晰可控。</p>
<div className="card mt-10 max-w-lg p-5">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold"><ArrowDownToLine size={17} className="text-leaf" />收到分享?直接取件</div>
<div className="grid gap-2 sm:grid-cols-[1fr_130px_auto]">
<input className="field" maxLength={8} placeholder="8 位文件码" value={pickup.hash} onChange={(e) => setPickup({ ...pickup, hash: e.target.value })} />
<input className="field" inputMode="numeric" maxLength={6} placeholder="取件码(可选)" value={pickup.code} onChange={(e) => setPickup({ ...pickup, code: e.target.value.replace(/\D/g, '') })} />
<button className="btn-secondary" onClick={() => retrieve(false)}>下载</button>
</div>
</div>
</section>
<section className="mx-auto w-full max-w-md py-8 lg:justify-self-end">
<form className="card p-7 sm:p-9" onSubmit={submit}>
<div className="mb-8"><div className="mb-4 grid size-10 place-items-center rounded-xl bg-mint text-leaf"><KeyRound size={20} /></div><h2 className="text-2xl font-semibold tracking-tight">登录管理空间</h2><p className="mt-1.5 text-sm text-ink/50">上传文件或管理系统用户</p></div>
<label className="label">用户名</label><input autoFocus className="field mb-4" required value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
<label className="label">密码</label><input className="field" type="password" required value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <LoaderCircle className="animate-spin" size={18} /> : '登录'}</button>
</form>
</section>
</main>
}
function ChangePassword() {
const user = useAppStore((s) => s.user)
const setUser = useAppStore((s) => s.setUser)
const logout = useAppStore((s) => s.logout)
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' })
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
if (form.newPassword !== form.confirm) return setError('两次输入的新密码不一致')
try { setUser(await api('/api/auth/password', json('PUT', form))) }
catch (err) { setError(err.message) }
}
return <main className="noise grid min-h-screen place-items-center px-5"><form onSubmit={submit} className="card w-full max-w-md p-8">
<div className="mb-5 grid size-12 place-items-center rounded-2xl bg-amber-100 text-amber-700"><KeyRound /></div>
<h1 className="text-2xl font-semibold">先设置你的新密码</h1><p className="mt-2 text-sm leading-6 text-ink/55">你好,{user.username}。临时密码只能用于首次登录,新密码至少 8 位。</p>
<label className="label mt-7">当前临时密码</label><input className="field" type="password" required value={form.currentPassword} onChange={(e) => setForm({ ...form, currentPassword: e.target.value })} />
<label className="label mt-4">新密码</label><input className="field" type="password" minLength={8} required value={form.newPassword} onChange={(e) => setForm({ ...form, newPassword: e.target.value })} />
<label className="label mt-4">再次输入新密码</label><input className="field" type="password" minLength={8} required value={form.confirm} onChange={(e) => setForm({ ...form, confirm: e.target.value })} />
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<button className="btn-primary mt-6 w-full">完成设置</button><button type="button" className="mt-3 w-full py-2 text-sm text-ink/45 hover:text-ink" onClick={logout}>退出登录</button>
</form></main>
}
function UploadPanel() {
const user = useAppStore((s) => s.user)
const [file, setFile] = useState(null)
const [retention, setRetention] = useState('1d')
const [downloads, setDownloads] = useState('5')
const [customDownloads, setCustomDownloads] = useState('20')
const [pickupCode, setPickupCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null)
const allowed = user.canUpload || user.canAdvancedUpload
const submit = async (event) => {
event.preventDefault(); if (!file) return
setBusy(true); setError('')
const data = new FormData(); data.set('file', file); data.set('retention', retention)
data.set('downloads', downloads === 'custom' ? customDownloads : downloads); data.set('pickupCode', pickupCode)
try { const uploaded = await api('/api/files', { method: 'POST', body: data }); setResult({ ...uploaded, pickupCode }); setFile(null) }
catch (e) { setError(e.message) }
finally { setBusy(false) }
}
if (!allowed) return <div className="card p-8 text-center"><ShieldCheck className="mx-auto mb-3 text-ink/25" size={32} /><h2 className="font-semibold">当前账号没有上传权限</h2><p className="mt-1 text-sm text-ink/50">请联系用户管理员调整权限。</p></div>
if (result) return <UploadResult file={result} onAgain={() => { setResult(null); setPickupCode('') }} />
return <form className="card p-5 sm:p-6" onSubmit={submit}>
<div className="mb-5 flex items-start justify-between"><div><h2 className="text-lg font-semibold">上传新文件</h2><p className="mt-1 text-sm text-ink/45">{user.canAdvancedUpload ? '高级上传 · 不限有效文件数量' : '普通上传 · 最多 10 个有效文件'}</p></div><UploadCloud className="text-leaf" /></div>
<label className="group grid min-h-36 cursor-pointer place-items-center rounded-2xl border border-dashed border-ink/20 bg-paper/60 p-5 text-center transition hover:border-leaf/50 hover:bg-mint/25">
<input className="hidden" type="file" onChange={(e) => setFile(e.target.files[0] || null)} />
<div><FileUp className="mx-auto mb-3 text-leaf" /><p className="max-w-sm truncate font-medium">{file ? file.name : '点击选择文件'}</p><p className="mt-1 text-xs text-ink/40">{file ? formatBytes(file.size) : '文件大小取决于服务器可用空间'}</p></div>
</label>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div><label className="label">保留时间</label><select className="field" value={retention} onChange={(e) => setRetention(e.target.value)}><option value="1h">1 小时</option><option value="8h">8 小时</option><option value="1d">1 天</option><option value="3d">3 天</option>{user.canAdvancedUpload && <option value="forever">永久</option>}</select></div>
<div><label className="label">可访问次数</label><select className="field" value={downloads} onChange={(e) => setDownloads(e.target.value)}><option value="1">1 次</option><option value="3">3 次</option><option value="5">5 次</option><option value="10">10 次</option><option value="custom">自定义</option>{user.canAdvancedUpload && <option value="unlimited">无限</option>}</select></div>
{downloads === 'custom' && <div><label className="label">自定义次数(1–99)</label><input className="field" type="number" min="1" max="99" required value={customDownloads} onChange={(e) => setCustomDownloads(e.target.value)} /></div>}
<div className={downloads === 'custom' ? '' : 'sm:col-span-2'}><label className="label">6 位数字取件码(可选)</label><input className="field" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="留空则无需取件码" value={pickupCode} onChange={(e) => setPickupCode(e.target.value.replace(/\D/g, ''))} /></div>
</div>
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
<button className="btn-primary mt-5 w-full" disabled={!file || busy}>{busy ? <><LoaderCircle className="animate-spin" size={18} />正在上传</> : <><UploadCloud size={18} />上传并生成链接</>}</button>
</form>
}
function UploadResult({ file, onAgain }) {
const [copied, setCopied] = useState('')
const query = new URLSearchParams()
if (file.pickupCode) query.set('code', file.pickupCode)
const download = `${location.origin}/uf/${file.hash}${query.size ? `?${query}` : ''}`
query.set('preview', 'true')
const preview = `${location.origin}/uf/${file.hash}?${query}`
const copy = async (value, type) => { await navigator.clipboard.writeText(value); setCopied(type); setTimeout(() => setCopied(''), 1800) }
const row = (title, value, type, icon) => <div><label className="label">{title}</label><div className="flex gap-2"><div className="field min-w-0 flex-1 truncate font-mono text-sm">{value}</div><button className="btn-secondary shrink-0 !px-3" onClick={() => copy(value, type)}>{copied === type ? <Check size={17} /> : icon}</button></div></div>
return <section className="card mx-auto w-full max-w-2xl p-6 sm:p-8">
<div className="mb-7 flex items-start gap-4"><div className="grid size-12 shrink-0 place-items-center rounded-2xl bg-mint text-leaf"><Check size={24} /></div><div className="min-w-0"><h2 className="text-xl font-semibold">文件已准备好</h2><p className="mt-1 truncate text-sm text-ink/50">{file.name} · {formatBytes(file.size)}</p></div></div>
<div className="space-y-4">{row('文件码', file.hash, 'hash', <Copy size={17} />)}{row('下载链接', download, 'download', <Copy size={17} />)}{row('预览链接', preview, 'preview', <Eye size={17} />)}{file.pickupCode && row('取件码', file.pickupCode, 'code', <Copy size={17} />)}</div>
<div className="mt-5 rounded-xl bg-paper p-4 text-sm text-ink/55">有效期:{formatDate(file.expiresAt)} · 访问次数:{file.maxDownloads == null ? '无限' : `${file.maxDownloads} 次`} {file.pickupCode && '· 链接已包含取件码,可按需分开发送'}</div>
<button className="btn-primary mt-6 w-full" onClick={onAgain}><UploadCloud size={18} />继续上传</button>
</section>
}
const emptyUser = { username: '', canManageUsers: false, canUpload: true, canAdvancedUpload: false }
function UserModal({ value, onClose, onSaved }) {
const [form, setForm] = useState(value || emptyUser)
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
try { onSaved(await api(value ? `/api/users/${value.id}` : '/api/users', json(value ? 'PUT' : 'POST', form))) }
catch (err) { setError(err.message) }
}
const checkbox = (key, title, hint) => <label className="flex cursor-pointer gap-3 rounded-xl border border-line p-3.5"><input className="mt-1 size-4 accent-leaf" type="checkbox" checked={form[key]} onChange={(e) => setForm({ ...form, [key]: e.target.checked })} /><span><span className="block text-sm font-medium">{title}</span><span className="mt-0.5 block text-xs text-ink/45">{hint}</span></span></label>
return <div className="fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><form onSubmit={submit} className="card w-full max-w-md p-6"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold">{value ? '编辑用户' : '创建用户'}</h2><button type="button" onClick={onClose}><X size={20} /></button></div><label className="label mt-5">用户名</label><input className="field" required pattern="[A-Za-z0-9_.-]{3,32}" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /><div className="mt-4 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件')}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载')}</div>{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<div className="mt-6 flex justify-end gap-2"><button type="button" className="btn-secondary" onClick={onClose}>取消</button><button className="btn-primary">保存</button></div></form></div>
}
function UsersPanel({ notify }) {
const users = useAppStore((s) => s.users)
const loadUsers = useAppStore((s) => s.loadUsers)
const [editing, setEditing] = useState(undefined)
const [modalOpen, setModalOpen] = useState(false)
const [secret, setSecret] = useState(null)
useEffect(() => { loadUsers() }, [loadUsers])
const saved = async (result) => { setModalOpen(false); setEditing(undefined); await loadUsers(); if (result.temporaryPassword) setSecret({ title: '用户已创建', password: result.temporaryPassword }); else notify('用户信息已保存') }
const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) }
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') }
return <section className="card overflow-hidden"><div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">用户管理</h2><p className="mt-0.5 text-xs text-ink/40">权限修改立即生效</p></div><button className="btn-primary !py-2 text-sm" onClick={() => { setEditing(undefined); setModalOpen(true) }}>创建用户</button></div><div className="divide-y divide-line">{users.map((user) => <div key={user.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center"><div className="grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf">{user.username.slice(0, 1).toUpperCase()}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="font-medium">{user.username}</p><span className="text-xs text-ink/35">#{user.id}</span>{user.mustChangePassword && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700">待改密</span>}</div><p className="mt-1 text-xs text-ink/45">{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div><div className="flex gap-2"><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => { setEditing(user); setModalOpen(true) }}>编辑</button><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => reset(user)}>重置密码</button>{user.id !== 1 && <button className="rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick={() => remove(user)}>删除</button>}</div></div>)}</div>
{modalOpen && <UserModal value={editing} onClose={() => setModalOpen(false)} onSaved={saved} />}
{secret && <div className="fixed inset-0 z-50 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><div className="card w-full max-w-md p-7"><h2 className="text-lg font-semibold">{secret.title}</h2><p className="mt-2 text-sm text-ink/50">该密码只显示这一次,用户登录后必须修改。</p><div className="mt-5 flex items-center justify-between rounded-xl bg-paper p-4 font-mono text-sm"><span className="break-all">{secret.password}</span><button onClick={() => navigator.clipboard.writeText(secret.password)}><Copy size={17} /></button></div><button className="btn-primary mt-5 w-full" onClick={() => setSecret(null)}>我已保存</button></div></div>}
</section>
}
function Dashboard() {
const user = useAppStore((s) => s.user)
const logout = useAppStore((s) => s.logout)
const [tab, setTab] = useState('files')
const [toast, setToast] = useState('')
const notify = (message) => setToast(message)
return <div className="noise min-h-screen"><header className="sticky top-0 z-20 border-b border-line/80 bg-paper/90 backdrop-blur-xl"><div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-3.5"><div className="flex items-center gap-2.5"><div className="grid size-9 place-items-center rounded-xl bg-leaf text-white"><FileArchive size={19} /></div><span className="font-semibold">ufile</span></div><div className="flex items-center gap-3"><span className="hidden text-sm text-ink/50 sm:block">{user.username}</span><button className="btn-secondary !p-2.5" title="退出登录" onClick={logout}><LogOut size={16} /></button></div></div></header>
<main className="mx-auto max-w-6xl px-5 py-7"><div className="mb-7 flex gap-1 rounded-xl bg-ink/5 p-1 sm:w-fit"><button className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'files' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('files')}><FileUp size={16} />文件空间</button>{user.canManageUsers && <button className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'users' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('users')}><Users size={16} />用户管理</button>}</div>
{tab === 'files' ? <div className="mx-auto max-w-2xl"><UploadPanel /></div> : <UsersPanel notify={notify} />}
</main>{toast && <Toast message={toast} onClose={() => setToast('')} />}
</div>
}
export default function App() {
const { user, checking, checkSession } = useAppStore()
useEffect(() => { checkSession() }, [checkSession])
if (checking) return <div className="grid min-h-screen place-items-center bg-paper"><LoaderCircle className="animate-spin text-leaf" /></div>
if (!user) return <Login />
if (user.mustChangePassword) return <ChangePassword />
return <Dashboard />
}
+18
View File
@@ -0,0 +1,18 @@
export async function api(path, options = {}) {
const response = await fetch(path, options)
if (response.status === 204) return null
const body = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(body.error || '请求失败')
error.code = body.code
error.status = response.status
throw error
}
return body
}
export const json = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
@theme {
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
--color-ink: #17211b;
--color-paper: #f6f7f2;
--color-leaf: #176b45;
--color-mint: #d8eee1;
--color-line: #dce2da;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #f6f7f2; color: #17211b; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.noise {
background-image: radial-gradient(rgba(23, 33, 27, .08) .55px, transparent .55px);
background-size: 6px 6px;
}
.card { @apply rounded-2xl border border-line bg-white shadow-[0_12px_35px_rgba(23,33,27,0.06)]; }
.field { @apply w-full rounded-xl border border-line bg-white px-3.5 py-2.5 outline-none transition focus:border-leaf focus:ring-3 focus:ring-leaf/10; }
.btn-primary { @apply inline-flex items-center justify-center gap-2 rounded-xl bg-leaf px-4 py-2.5 font-medium text-white transition hover:bg-[#105638] disabled:cursor-not-allowed disabled:opacity-50; }
.btn-secondary { @apply inline-flex items-center justify-center gap-2 rounded-xl border border-line bg-white px-4 py-2.5 font-medium transition hover:border-leaf/40 hover:bg-mint/30 disabled:opacity-50; }
.label { @apply mb-1.5 block text-sm font-medium text-ink/70; }
+8
View File
@@ -0,0 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>,
)
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand'
import { api, json } from './api'
export const useAppStore = create((set) => ({
user: null,
checking: true,
users: [],
async checkSession() {
try { set({ user: await api('/api/auth/me') }) }
catch { set({ user: null }) }
finally { set({ checking: false }) }
},
async login(username, password) {
const user = await api('/api/auth/login', json('POST', { username, password }))
set({ user })
},
async logout() {
await api('/api/auth/logout', { method: 'POST' })
set({ user: null, users: [] })
},
setUser: (user) => set({ user }),
async loadUsers() { set({ users: await api('/api/users') }) },
}))
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:6616',
'/uf': 'http://localhost:6616',
},
},
})
+48
View File
@@ -0,0 +1,48 @@
module ufile
go 1.24.0
require (
github.com/gin-gonic/gin v1.10.1
github.com/glebarez/sqlite v1.11.0
golang.org/x/crypto v0.36.0
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+116
View File
@@ -0,0 +1,116 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
)
//go:embed all:frontend/dist
var frontendAssets embed.FS
func main() {
dataPath := flag.String("data", "data", "文件保存目录")
dbPath := flag.String("db", "", "SQLite 数据库路径(默认 <data>/data.db)")
address := flag.String("address", "0.0.0.0:6616", "监听地址")
flag.Parse()
if *dbPath == "" {
*dbPath = filepath.Join(*dataPath, "data.db")
}
if err := os.MkdirAll(filepath.Dir(*dbPath), 0o750); err != nil {
log.Fatal(err)
}
web, err := fs.Sub(frontendAssets, "frontend/dist")
if err != nil {
log.Fatal(err)
}
app, initialPassword, err := newApp(*dbPath, *dataPath, web)
if err != nil {
log.Fatal(err)
}
if initialPassword != "" {
fmt.Printf("\n首次启动管理员账号:admin\n随机密码:%s\n登录后必须立即修改密码。\n\n", initialPassword)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go app.runCleanup(ctx)
server := &http.Server{Addr: *address, Handler: app.router(), ReadHeaderTimeout: 10 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
log.Printf("ufile listening on %s", *address)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import "time"
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:32;not null" json:"username"`
PasswordHash string `json:"-"`
CanManageUsers bool `json:"canManageUsers"`
CanUpload bool `json:"canUpload"`
CanAdvancedUpload bool `json:"canAdvancedUpload"`
MustChangePassword bool `json:"mustChangePassword"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Session struct {
ID uint `gorm:"primaryKey"`
TokenHash string `gorm:"uniqueIndex;size:64;not null"`
UserID uint `gorm:"index;not null"`
ExpiresAt time.Time `gorm:"index;not null"`
CreatedAt time.Time
}
type SharedFile struct {
ID uint `gorm:"primaryKey" json:"id"`
Hash string `gorm:"uniqueIndex;size:8;not null" json:"hash"`
OriginalName string `gorm:"not null" json:"name"`
MimeType string `gorm:"not null" json:"mimeType"`
Size int64 `json:"size"`
OwnerID uint `gorm:"index;not null" json:"ownerId"`
ExpiresAt *time.Time `gorm:"index" json:"expiresAt"`
MaxDownloads *int `json:"maxDownloads"`
DownloadCount int `gorm:"not null;default:0" json:"downloadCount"`
PickupCodeHash string `json:"-"`
Ready bool `gorm:"index;not null;default:false" json:"-"`
CreatedAt time.Time `json:"createdAt"`
}
func (f SharedFile) HasPickupCode() bool { return f.PickupCodeHash != "" }
type fileResponse struct {
ID uint `json:"id"`
Hash string `json:"hash"`
Name string `json:"name"`
MimeType string `json:"mimeType"`
Size int64 `json:"size"`
ExpiresAt *time.Time `json:"expiresAt"`
MaxDownloads *int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
HasPickupCode bool `json:"hasPickupCode"`
CreatedAt time.Time `json:"createdAt"`
}
func presentFile(f SharedFile) fileResponse {
return fileResponse{
ID: f.ID, Hash: f.Hash, Name: f.OriginalName, MimeType: f.MimeType,
Size: f.Size, ExpiresAt: f.ExpiresAt, MaxDownloads: f.MaxDownloads,
DownloadCount: f.DownloadCount, HasPickupCode: f.HasPickupCode(), CreatedAt: f.CreatedAt,
}
}