feat: secure pickup flow and add deployment
Release / deploy (push) Successful in 4m1s

This commit is contained in:
loveuer
2026-09-10 02:44:35 -07:00
parent fa4357a3f2
commit 8ca8341d7c
8 changed files with 276 additions and 34 deletions
+8
View File
@@ -0,0 +1,8 @@
.git/
data/
ufile
frontend/node_modules/
frontend/dist/
*.db
*.db-shm
*.db-wal
+42
View File
@@ -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
+35
View File
@@ -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"]
+2
View File
@@ -35,3 +35,5 @@
- 有取件码时追加:`code=<6位取件码>` - 有取件码时追加:`code=<6位取件码>`
预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。 预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。
设置取件码的文件会统一记录校验、下载和预览请求中的连续失败;缺失或错误的取件码累计 5 次后,文件会立即从数据库和磁盘删除,正确校验会清零失败次数。
+99 -12
View File
@@ -30,6 +30,12 @@ const sessionCookie = "ufile_session"
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`) var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`) 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 { type App struct {
db *gorm.DB db *gorm.DB
@@ -37,6 +43,7 @@ type App struct {
web fs.FS web fs.FS
uploadMu sync.Mutex uploadMu sync.Mutex
downloadMu sync.Mutex downloadMu sync.Mutex
pickupMu sync.Mutex
now func() time.Time 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.POST("/api/auth/logout", a.auth(false), a.logout)
r.GET("/api/auth/me", a.auth(false), a.me) r.GET("/api/auth/me", a.auth(false), a.me)
r.PUT("/api/auth/password", a.auth(false), a.changePassword) 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.POST("/api/files", a.auth(true), a.uploadFile)
r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers) r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers)
r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser) 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) { func (a *App) downloadFile(c *gin.Context) {
var file SharedFile file, err := a.resolvePublicFile(c.Param("hash"), c.Query("code"))
if err := a.db.Where("hash = ? AND ready = ?", c.Param("hash"), true).First(&file).Error; err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"}) writePublicFileError(c, err)
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 return
} }
handle, err := os.Open(filepath.Join(a.filesDir, file.Hash)) 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) _, _ = 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) { func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) {
a.downloadMu.Lock() a.downloadMu.Lock()
defer a.downloadMu.Unlock() defer a.downloadMu.Unlock()
+59 -3
View File
@@ -132,9 +132,25 @@ func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
denied := request(t, router, http.MethodGet, "/uf/"+file.Hash, nil, "", nil) missingCode := request(t, router, http.MethodGet, "/api/pickup/"+file.Hash, nil, "", nil)
if denied.Code != http.StatusForbidden { if missingCode.Code != http.StatusForbidden || !strings.Contains(missingCode.Body.String(), "请输入取件码") {
t.Fatalf("missing code status = %d", denied.Code) 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) preview := request(t, router, http.MethodGet, "/uf/"+file.Hash+"?preview=true&code=123456", nil, "", nil)
if preview.Code != http.StatusOK || preview.Body.String() != "hello" { 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) { func TestFiniteDownloadCountDeletesAfterMoreThanOneAccess(t *testing.T) {
app, _ := testApp(t) app, _ := testApp(t)
max := 3 max := 3
+18 -7
View File
@@ -31,13 +31,23 @@ function Login() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [pickup, setPickup] = useState({ hash: '', code: '' }) 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() 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() const query = new URLSearchParams()
if (pickup.code) query.set('code', pickup.code) if (pickup.code) query.set('code', pickup.code)
if (preview) query.set('preview', 'true') setPickupBusy(true)
window.open(`/uf/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`, '_blank') 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) => { const submit = async (event) => {
event.preventDefault(); setBusy(true); setError('') event.preventDefault(); setBusy(true); setError('')
@@ -54,10 +64,11 @@ function Login() {
<div className="card mt-10 max-w-lg p-5"> <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="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]"> <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" maxLength={8} placeholder="8 位文件码" value={pickup.hash} onChange={(e) => { setPickup({ ...pickup, hash: e.target.value }); setPickupError('') }} />
<input className="field" inputMode="numeric" maxLength={6} placeholder="取件码(可选)" value={pickup.code} onChange={(e) => setPickup({ ...pickup, code: e.target.value.replace(/\D/g, '') })} /> <input className="field" inputMode="numeric" maxLength={6} placeholder="取件码(可选)" value={pickup.code} onChange={(e) => { setPickup({ ...pickup, code: e.target.value.replace(/\D/g, '') }); setPickupError('') }} />
<button className="btn-secondary" onClick={() => retrieve(false)}>下载</button> <button className="btn-secondary" disabled={pickupBusy} onClick={retrieve}>{pickupBusy ? <LoaderCircle className="animate-spin" size={17} /> : '下载'}</button>
</div> </div>
{pickupError && <p className="mt-2 text-sm text-red-600">{pickupError}</p>}
</div> </div>
</section> </section>
<section className="mx-auto w-full max-w-md py-8 lg:justify-self-end"> <section className="mx-auto w-full max-w-md py-8 lg:justify-self-end">
+1
View File
@@ -33,6 +33,7 @@ type SharedFile struct {
MaxDownloads *int `json:"maxDownloads"` MaxDownloads *int `json:"maxDownloads"`
DownloadCount int `gorm:"not null;default:0" json:"downloadCount"` DownloadCount int `gorm:"not null;default:0" json:"downloadCount"`
PickupCodeHash string `json:"-"` PickupCodeHash string `json:"-"`
FailedCodeAttempts int `gorm:"not null;default:0" json:"-"`
Ready bool `gorm:"index;not null;default:false" json:"-"` Ready bool `gorm:"index;not null;default:false" json:"-"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }