diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7916dff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git/ +data/ +ufile +frontend/node_modules/ +frontend/dist/ +*.db +*.db-shm +*.db-wal diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..2281636 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,42 @@ +name: Release + +on: + push: + branches: + - master + +jobs: + deploy: + runs-on: tencent-local + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: docker build --build-arg GOPROXY=https://goproxy.cn,direct -t ufile:latest . + + - name: Stop old container + run: | + if docker container inspect ufile >/dev/null 2>&1; then + docker stop ufile + fi + + - name: Remove old container + run: | + if docker container inspect ufile >/dev/null 2>&1; then + docker rm ufile + fi + + - name: Start new container + run: | + mkdir -p /root/deploy/ufile/data + chown -R 10001:10001 /root/deploy/ufile/data + docker run -d \ + --name ufile \ + --restart unless-stopped \ + -p 6616:6616 \ + -v /root/deploy/ufile/data:/data \ + ufile:latest \ + /usr/local/bin/ufile \ + --data /data \ + --address 0.0.0.0:6616 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6043e06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:24-alpine AS frontend + +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM golang:1.24-alpine AS backend + +ARG GOPROXY=https://proxy.golang.org,direct +ENV GOPROXY=${GOPROXY} + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +COPY --from=frontend /src/frontend/dist ./frontend/dist +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /ufile . + +FROM alpine:3.22 + +RUN addgroup -S -g 10001 ufile \ + && adduser -S -D -H -u 10001 -G ufile ufile \ + && mkdir -p /data \ + && chown ufile:ufile /data + +COPY --from=backend --chown=ufile:ufile /ufile /usr/local/bin/ufile + +USER ufile +WORKDIR /data +VOLUME ["/data"] +EXPOSE 6616 + +CMD ["/usr/local/bin/ufile", "--data", "/data", "--address", "0.0.0.0:6616"] diff --git a/README.md b/README.md index d4d9ed9..ac98167 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,5 @@ - 有取件码时追加:`code=<6位取件码>` 预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。 + +设置取件码的文件会统一记录校验、下载和预览请求中的连续失败;缺失或错误的取件码累计 5 次后,文件会立即从数据库和磁盘删除,正确校验会清零失败次数。 diff --git a/app.go b/app.go index 7126061..9647c91 100644 --- a/app.go +++ b/app.go @@ -30,6 +30,12 @@ const sessionCookie = "ufile_session" var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`) var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`) +var errFileUnavailable = errors.New("文件不存在或已失效") +var errPickupCodeRequired = errors.New("请输入取件码") +var errPickupCodeWrong = errors.New("取件码错误") +var errFileCheckFailed = errors.New("校验文件失败") + +const publicFileOrCodeError = "文件不存在或取件码错误" type App struct { db *gorm.DB @@ -37,6 +43,7 @@ type App struct { web fs.FS uploadMu sync.Mutex downloadMu sync.Mutex + pickupMu sync.Mutex now func() time.Time } @@ -88,6 +95,7 @@ func (a *App) router() *gin.Engine { 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.GET("/api/pickup/:hash", a.verifyPickup) 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) @@ -407,18 +415,9 @@ func (a *App) uploadFile(c *gin.Context) { } 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": "取件码错误"}) + file, err := a.resolvePublicFile(c.Param("hash"), c.Query("code")) + if err != nil { + writePublicFileError(c, err) return } handle, err := os.Open(filepath.Join(a.filesDir, file.Hash)) @@ -459,6 +458,94 @@ func (a *App) downloadFile(c *gin.Context) { _, _ = io.Copy(c.Writer, handle) } +func (a *App) verifyPickup(c *gin.Context) { + if _, err := a.resolvePublicFile(c.Param("hash"), c.Query("code")); err != nil { + writePublicFileError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"valid": true}) +} + +func (a *App) resolvePublicFile(hash, pickupCode string) (SharedFile, error) { + var file SharedFile + if err := a.db.Where("hash = ? AND ready = ?", hash, true).First(&file).Error; err != nil { + return SharedFile{}, errFileUnavailable + } + if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) { + a.removeFile(file) + return SharedFile{}, errFileUnavailable + } + if file.MaxDownloads != nil && file.DownloadCount >= *file.MaxDownloads { + a.removeFile(file) + return SharedFile{}, errFileUnavailable + } + if _, err := os.Stat(filepath.Join(a.filesDir, file.Hash)); err != nil { + a.db.Delete(&file) + return SharedFile{}, errFileUnavailable + } + if file.PickupCodeHash == "" { + return file, nil + } + if pickupCode == "" { + return SharedFile{}, a.recordPickupFailure(file, errPickupCodeRequired) + } + if bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(pickupCode)) != nil { + return SharedFile{}, a.recordPickupFailure(file, errPickupCodeWrong) + } + if err := a.clearPickupFailures(file); err != nil { + return SharedFile{}, err + } + return file, nil +} + +func (a *App) recordPickupFailure(file SharedFile, failure error) error { + a.pickupMu.Lock() + defer a.pickupMu.Unlock() + result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", gorm.Expr("failed_code_attempts + 1")) + if result.Error != nil { + return errFileCheckFailed + } + if result.RowsAffected == 0 { + return errFileUnavailable + } + var latest SharedFile + if err := a.db.Select("id", "hash", "failed_code_attempts").First(&latest, file.ID).Error; err != nil { + return errFileUnavailable + } + if latest.FailedCodeAttempts < 5 { + return failure + } + a.removeFile(file) + return errFileUnavailable +} + +func (a *App) clearPickupFailures(file SharedFile) error { + if file.FailedCodeAttempts == 0 { + return nil + } + a.pickupMu.Lock() + defer a.pickupMu.Unlock() + result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", 0) + if result.Error != nil { + return errFileCheckFailed + } + if result.RowsAffected == 0 { + return errFileUnavailable + } + return nil +} + +func writePublicFileError(c *gin.Context, err error) { + switch { + case errors.Is(err, errFileUnavailable), errors.Is(err, errPickupCodeWrong): + c.JSON(http.StatusNotFound, gin.H{"error": publicFileOrCodeError}) + case errors.Is(err, errPickupCodeRequired): + c.JSON(http.StatusForbidden, gin.H{"error": errPickupCodeRequired.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": errFileCheckFailed.Error()}) + } +} + func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) { a.downloadMu.Lock() defer a.downloadMu.Unlock() diff --git a/app_test.go b/app_test.go index b06c403..fd88526 100644 --- a/app_test.go +++ b/app_test.go @@ -132,9 +132,25 @@ func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) { 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) + missingCode := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash, nil, "", nil) + if missingCode.Code != http.StatusForbidden || !strings.Contains(missingCode.Body.String(), "请输入取件码") { + t.Fatalf("missing code status = %d, body = %s", missingCode.Code, missingCode.Body.String()) + } + wrongCode := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash+"?code=654321", nil, "", nil) + if wrongCode.Code != http.StatusNotFound || !strings.Contains(wrongCode.Body.String(), publicFileOrCodeError) { + t.Fatalf("wrong code status = %d, body = %s", wrongCode.Code, wrongCode.Body.String()) + } + missingFile := request(t, router, http.MethodGet, "/api/pickup/notfound?code=654321", nil, "", nil) + if missingFile.Code != wrongCode.Code || missingFile.Body.String() != wrongCode.Body.String() { + t.Fatalf("missing file and wrong code responses differ: missing = %d %s, wrong = %d %s", missingFile.Code, missingFile.Body.String(), wrongCode.Code, wrongCode.Body.String()) + } + verified := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash+"?code=123456", nil, "", nil) + if verified.Code != http.StatusOK { + t.Fatalf("verify status = %d, body = %s", verified.Code, verified.Body.String()) + } + var stored SharedFile + if err := app.db.Where("hash = ?", file.Hash).First(&stored).Error; err != nil || stored.DownloadCount != 0 || stored.FailedCodeAttempts != 0 { + t.Fatalf("verification state: downloads = %d, code failures = %d, err = %v", stored.DownloadCount, stored.FailedCodeAttempts, err) } preview := request(t, router, http.MethodGet, "/uf/"+file.Hash+"?preview=true&code=123456", nil, "", nil) if preview.Code != http.StatusOK || preview.Body.String() != "hello" { @@ -157,6 +173,46 @@ func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) { } } +func TestFiveConsecutivePickupCodeFailuresDeleteFile(t *testing.T) { + app, _ := testApp(t) + codeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost) + max := 10 + file := SharedFile{Hash: "badcode5", OriginalName: "secret.txt", MimeType: "text/plain", OwnerID: 1, MaxDownloads: &max, PickupCodeHash: string(codeHash), Ready: true, Size: 6} + 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("secret"), 0o640); err != nil { + t.Fatal(err) + } + router := app.router() + requests := []string{ + "/api/pickup/" + file.Hash, + "/uf/" + file.Hash + "?code=000001", + "/api/pickup/" + file.Hash + "?code=000002", + "/uf/" + file.Hash + "?code=000003", + "/api/pickup/" + file.Hash + "?code=000004", + } + for index, path := range requests { + response := request(t, router, http.MethodGet, path, nil, "", nil) + want := http.StatusNotFound + if index == 0 { + want = http.StatusForbidden + } + if response.Code != want { + t.Fatalf("failure %d status = %d, want %d, body = %s", index+1, response.Code, want, response.Body.String()) + } + } + var count int64 + app.db.Model(&SharedFile{}).Where("id = ?", file.ID).Count(&count) + if count != 0 { + t.Fatal("file record remains after five code failures") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("file remains on disk after five code failures") + } +} + func TestFiniteDownloadCountDeletesAfterMoreThanOneAccess(t *testing.T) { app, _ := testApp(t) max := 3 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 81ba167..835d72a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,13 +31,23 @@ function Login() { const [error, setError] = useState('') const [busy, setBusy] = useState(false) const [pickup, setPickup] = useState({ hash: '', code: '' }) - const retrieve = (preview = false) => { + const [pickupError, setPickupError] = useState('') + const [pickupBusy, setPickupBusy] = useState(false) + const retrieve = async () => { const hash = pickup.hash.trim() - if (!hash) return + setPickupError('') + if (hash.length !== 8) return setPickupError('请输入 8 位文件码') + if (pickup.code && pickup.code.length !== 6) return setPickupError('取件码必须是 6 位数字') 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') + setPickupBusy(true) + try { + await api(`/api/pickup/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`) + const link = document.createElement('a') + link.href = `/uf/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}` + document.body.appendChild(link); link.click(); link.remove() + } catch (error) { setPickupError(error.message) } + finally { setPickupBusy(false) } } const submit = async (event) => { event.preventDefault(); setBusy(true); setError('') @@ -54,10 +64,11 @@ function Login() {
{pickupError}
}