This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.git/
|
||||
data/
|
||||
ufile
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -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
@@ -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"]
|
||||
@@ -35,3 +35,5 @@
|
||||
- 有取件码时追加:`code=<6位取件码>`
|
||||
|
||||
预览也会消耗一次访问次数。文件过期、访问次数耗尽或所属用户被删除时,数据库记录和磁盘文件都会被清理。
|
||||
|
||||
设置取件码的文件会统一记录校验、下载和预览请求中的连续失败;缺失或错误的取件码累计 5 次后,文件会立即从数据库和磁盘删除,正确校验会清零失败次数。
|
||||
|
||||
@@ -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()
|
||||
|
||||
+59
-3
@@ -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
|
||||
|
||||
+18
-7
@@ -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() {
|
||||
<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>
|
||||
<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, '') }); setPickupError('') }} />
|
||||
<button className="btn-secondary" disabled={pickupBusy} onClick={retrieve}>{pickupBusy ? <LoaderCircle className="animate-spin" size={17} /> : '下载'}</button>
|
||||
</div>
|
||||
{pickupError && <p className="mt-2 text-sm text-red-600">{pickupError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="mx-auto w-full max-w-md py-8 lg:justify-self-end">
|
||||
|
||||
@@ -23,18 +23,19 @@ type Session struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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:"-"`
|
||||
FailedCodeAttempts int `gorm:"not null;default:0" json:"-"`
|
||||
Ready bool `gorm:"index;not null;default:false" json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (f SharedFile) HasPickupCode() bool { return f.PickupCodeHash != "" }
|
||||
|
||||
Reference in New Issue
Block a user