feat: require advanced permission for large uploads
Release / deploy (push) Successful in 2m27s

This commit is contained in:
loveuer
2026-09-11 01:44:21 -07:00
parent ec53eb06ec
commit df8f7e8922
4 changed files with 48 additions and 6 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ufile
一个单二进制运行的轻量文件快传服务。支持多用户权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。文件管理员可以查看并删除全部当前有效文件。
一个单二进制运行的轻量文件快传服务。支持多用户权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。普通上传单文件最大 10 MB,高级上传不受此限制。文件管理员可以查看并删除全部当前有效文件。
## 构建
+5
View File
@@ -27,6 +27,7 @@ import (
)
const sessionCookie = "ufile_session"
const basicUploadMaxSize = 10 * 1024 * 1024
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
@@ -397,6 +398,10 @@ func (a *App) uploadFile(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
return
}
if header.Size > basicUploadMaxSize && !user.CanAdvancedUpload {
c.JSON(http.StatusForbidden, gin.H{"error": "超过 10 MB 的文件需要高级上传权限"})
return
}
a.uploadMu.Lock()
defer a.uploadMu.Unlock()
+33
View File
@@ -401,6 +401,39 @@ func TestBasicUploadLimitAndAdvancedInheritance(t *testing.T) {
}
}
func TestLargeFileRequiresAdvancedUpload(t *testing.T) {
app, _ := testApp(t)
passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost)
basic := User{Username: "basic-size", PasswordHash: string(passwordHash), CanUpload: true}
advanced := User{Username: "advanced-size", PasswordHash: string(passwordHash), CanAdvancedUpload: true}
for _, user := range []*User{&basic, &advanced} {
if err := app.db.Create(user).Error; err != nil {
t.Fatal(err)
}
}
router := app.router()
fields := map[string]string{"retention": "1h", "downloads": "1"}
basicCookie := loginAs(t, router, basic.Username, "password-123")
body, contentType := uploadBody(t, fields, "allowed.bin", make([]byte, 10*1024*1024))
response := request(t, router, http.MethodPost, "/api/files", body, contentType, basicCookie)
if response.Code != http.StatusCreated {
t.Fatalf("10 MB basic upload status = %d, body = %s", response.Code, response.Body.String())
}
body, contentType = uploadBody(t, fields, "blocked.bin", make([]byte, 10*1024*1024+1))
response = request(t, router, http.MethodPost, "/api/files", body, contentType, basicCookie)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "高级上传") {
t.Fatalf("large basic upload status = %d, body = %s", response.Code, response.Body.String())
}
advancedCookie := loginAs(t, router, advanced.Username, "password-123")
body, contentType = uploadBody(t, fields, "allowed-large.bin", make([]byte, 10*1024*1024+1))
response = request(t, router, http.MethodPost, "/api/files", body, contentType, advancedCookie)
if response.Code != http.StatusCreated {
t.Fatalf("large advanced upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDeleteUserRemovesOwnedFilesAndProtectsIDOne(t *testing.T) {
app, password := testApp(t)
var admin User
+9 -5
View File
@@ -20,6 +20,8 @@ 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)) : '永久'
const basicUploadMaxSize = 10 * 1024 * 1024
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>
@@ -116,6 +118,7 @@ function UploadPanel() {
const [error, setError] = useState('')
const [result, setResult] = useState(null)
const allowed = user.canUpload || user.canAdvancedUpload
const fileTooLarge = Boolean(file && !user.canAdvancedUpload && file.size > basicUploadMaxSize)
const generatePickupCode = () => {
const value = new Uint32Array(1)
crypto.getRandomValues(value)
@@ -123,6 +126,7 @@ function UploadPanel() {
}
const submit = async (event) => {
event.preventDefault(); if (!file) return
if (fileTooLarge) return setError('超过 10 MB 的文件需要高级上传权限')
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)
@@ -133,9 +137,9 @@ function UploadPanel() {
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>
<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 个有效文件 · 单文件最大 10 MB'}</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)} />
<input className="hidden" type="file" onChange={(e) => { setFile(e.target.files[0] || null); setError('') }} />
<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">
@@ -144,8 +148,8 @@ function UploadPanel() {
{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><div className="flex gap-2"><input className="field" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="留空则无需取件码" value={pickupCode} onChange={(e) => setPickupCode(e.target.value.replace(/\D/g, ''))} /><button className="btn-secondary shrink-0" type="button" onClick={generatePickupCode}>随机生成</button></div><p className={`mt-2 text-xs ${pickupCode ? 'text-leaf' : 'text-amber-700'}`}>{pickupCode ? '已启用取件码保护' : '强烈建议生成取件码;仅在确认文件无需保护时留空。'}</p></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>
{(error || fileTooLarge) && <p className="mt-4 text-sm text-red-600">{error || '超过 10 MB 的文件需要高级上传权限'}</p>}
<button className="btn-primary mt-5 w-full" disabled={!file || fileTooLarge || busy}>{busy ? <><LoaderCircle className="animate-spin" size={18} />正在上传</> : <><UploadCloud size={18} />上传并生成链接</>}</button>
</form>
}
@@ -177,7 +181,7 @@ function UserModal({ value, onClose, onSaved }) {
catch (err) { setError(err.message) }
}
const checkbox = (key, title, hint, disabled = false) => <label className={`flex gap-3 rounded-xl border border-line p-3.5 ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer'}`}><input className="mt-1 size-4 accent-leaf" type="checkbox" disabled={disabled} 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 max-h-[90vh] w-full max-w-md overflow-y-auto 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 })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canManageFiles', '文件管理', '查看并删除所有用户上传的文件', !actor.canManageFiles)}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.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>
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 max-h-[90vh] w-full max-w-md overflow-y-auto 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 })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canManageFiles', '文件管理', '查看并删除所有用户上传的文件', !actor.canManageFiles)}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件,单文件最大 10 MB', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许大文件、永久、无限下载', !actor.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 }) {