From 390741035421293eb9b96f67dd7c686223e7cede Mon Sep 17 00:00:00 2001 From: loveuer Date: Thu, 10 Sep 2026 20:40:57 -0700 Subject: [PATCH] feat: add file management and branding --- README.md | 2 +- app.go | 89 ++++++++++++++++++++++++++++++------ app_test.go | 78 ++++++++++++++++++++++++++++++- frontend/index.html | 1 + frontend/public/favicon.ico | Bin 0 -> 8465 bytes frontend/public/icon.svg | 6 +++ frontend/src/App.jsx | 38 +++++++++++---- frontend/src/store.js | 4 +- models.go | 27 ++++++----- 9 files changed, 209 insertions(+), 36 deletions(-) create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/icon.svg diff --git a/README.md b/README.md index ac98167..25e24dc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ufile -一个单二进制运行的轻量文件快传服务。支持多用户和三级权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。 +一个单二进制运行的轻量文件快传服务。支持多用户权限、限时保存、限次访问、6 位取件码,以及无需登录的下载和预览链接。文件管理员可以查看并删除全部当前有效文件。 ## 构建 diff --git a/app.go b/app.go index 9647c91..0682256 100644 --- a/app.go +++ b/app.go @@ -65,13 +65,14 @@ func newApp(dbPath, dataDir string, web fs.FS) (*App, string, error) { } func (a *App) ensureAdmin() (string, error) { - var count int64 - if err := a.db.Model(&User{}).Count(&count).Error; err != nil { + var admin User + if err := a.db.First(&admin, 1).Error; err == nil { + return "", a.db.Model(&admin).Updates(map[string]any{ + "can_manage_users": true, "can_manage_files": true, "can_upload": true, "can_advanced_upload": true, + }).Error + } else if !errors.Is(err, gorm.ErrRecordNotFound) { return "", err } - if count != 0 { - return "", nil - } password, err := randomToken(18) if err != nil { return "", err @@ -80,7 +81,7 @@ func (a *App) ensureAdmin() (string, error) { if err != nil { return "", err } - admin := User{ID: 1, Username: "admin", PasswordHash: string(hash), CanManageUsers: true, CanUpload: true, CanAdvancedUpload: true, MustChangePassword: true} + admin = User{ID: 1, Username: "admin", PasswordHash: string(hash), CanManageUsers: true, CanManageFiles: true, CanUpload: true, CanAdvancedUpload: true, MustChangePassword: true} if err := a.db.Create(&admin).Error; err != nil { return "", err } @@ -96,7 +97,9 @@ func (a *App) router() *gin.Engine { 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.GET("/api/files", a.auth(true), a.manageFiles, a.listManagedFiles) r.POST("/api/files", a.auth(true), a.uploadFile) + r.DELETE("/api/files/:id", a.auth(true), a.manageFiles, a.deleteManagedFile) 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) @@ -146,10 +149,21 @@ func (a *App) manageUsers(c *gin.Context) { c.Next() } -func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload bool) bool { +func (a *App) manageFiles(c *gin.Context) { + if !currentUser(c).CanManageFiles { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "没有文件管理权限"}) + return + } + c.Next() +} + +func permissionsWithin(actor *User, canManageUsers, canManageFiles, canUpload, canAdvancedUpload bool) bool { if canManageUsers && !actor.CanManageUsers { return false } + if canManageFiles && !actor.CanManageFiles { + return false + } if canAdvancedUpload && !actor.CanAdvancedUpload { return false } @@ -157,11 +171,11 @@ func permissionsWithin(actor *User, canManageUsers, canUpload, canAdvancedUpload } func canManageTarget(actor, target *User) bool { - return permissionsWithin(actor, target.CanManageUsers, target.CanUpload, target.CanAdvancedUpload) + return permissionsWithin(actor, target.CanManageUsers, target.CanManageFiles, target.CanUpload, target.CanAdvancedUpload) } -func requireAssignablePermissions(c *gin.Context, canManageUsers, canUpload, canAdvancedUpload bool) bool { - if permissionsWithin(currentUser(c), canManageUsers, canUpload, canAdvancedUpload) { +func requireAssignablePermissions(c *gin.Context, canManageUsers, canManageFiles, canUpload, canAdvancedUpload bool) bool { + if permissionsWithin(currentUser(c), canManageUsers, canManageFiles, canUpload, canAdvancedUpload) { return true } c.JSON(http.StatusForbidden, gin.H{"error": "不能授予自己不具备的权限"}) @@ -308,6 +322,51 @@ func parseDownloads(value string) (*int, bool) { return &n, true } +func (a *App) listManagedFiles(c *gin.Context) { + var files []SharedFile + if err := a.db.Where("ready = ? AND (expires_at IS NULL OR expires_at > ?)", true, a.now()).Order("created_at DESC").Find(&files).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件列表失败"}) + return + } + ownerIDs := make([]uint, 0, len(files)) + for _, file := range files { + ownerIDs = append(ownerIDs, file.OwnerID) + } + owners := make(map[uint]string, len(ownerIDs)) + if len(ownerIDs) > 0 { + var users []User + if err := a.db.Select("id", "username").Where("id IN ?", ownerIDs).Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取文件列表失败"}) + return + } + for _, user := range users { + owners[user.ID] = user.Username + } + } + result := make([]fileResponse, 0, len(files)) + for _, file := range files { + item := presentFile(file) + item.OwnerUsername = owners[file.OwnerID] + result = append(result, item) + } + c.JSON(http.StatusOK, result) +} + +func (a *App) deleteManagedFile(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件 ID"}) + return + } + var file SharedFile + if err := a.db.First(&file, uint(id)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"}) + return + } + a.removeFile(file) + c.Status(http.StatusNoContent) +} + func (a *App) uploadFile(c *gin.Context) { user := currentUser(c) if !user.CanUpload && !user.CanAdvancedUpload { @@ -672,6 +731,7 @@ func (a *App) createUser(c *gin.Context) { var req struct { Username string `json:"username"` CanManageUsers bool `json:"canManageUsers"` + CanManageFiles bool `json:"canManageFiles"` CanUpload bool `json:"canUpload"` CanAdvancedUpload bool `json:"canAdvancedUpload"` } @@ -679,7 +739,7 @@ func (a *App) createUser(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "用户名需为 3 到 32 位字母、数字、点、横线或下划线"}) return } - if !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) { + if !requireAssignablePermissions(c, req.CanManageUsers, req.CanManageFiles, req.CanUpload, req.CanAdvancedUpload) { return } password, err := randomToken(12) @@ -688,7 +748,7 @@ func (a *App) createUser(c *gin.Context) { 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} + user := User{Username: req.Username, PasswordHash: string(hash), CanManageUsers: req.CanManageUsers, CanManageFiles: req.CanManageFiles, 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": "用户名已存在"}) @@ -708,6 +768,7 @@ func (a *App) updateUser(c *gin.Context) { var req struct { Username string `json:"username"` CanManageUsers bool `json:"canManageUsers"` + CanManageFiles bool `json:"canManageFiles"` CanUpload bool `json:"canUpload"` CanAdvancedUpload bool `json:"canAdvancedUpload"` } @@ -720,10 +781,10 @@ func (a *App) updateUser(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return } - if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanUpload, req.CanAdvancedUpload) { + if !requireManageableTarget(c, &user) || !requireAssignablePermissions(c, req.CanManageUsers, req.CanManageFiles, req.CanUpload, req.CanAdvancedUpload) { 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 + err := a.db.Model(&user).Updates(map[string]any{"username": req.Username, "can_manage_users": req.CanManageUsers, "can_manage_files": req.CanManageFiles, "can_upload": req.CanUpload, "can_advanced_upload": req.CanAdvancedUpload}).Error if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"}) return diff --git a/app_test.go b/app_test.go index fd88526..cd54b0d 100644 --- a/app_test.go +++ b/app_test.go @@ -90,7 +90,7 @@ func TestBootstrapAdminAndForcePasswordChange(t *testing.T) { if err := app.db.First(&admin, 1).Error; err != nil { t.Fatal(err) } - if admin.Username != "admin" || !admin.MustChangePassword || !admin.CanManageUsers || !admin.CanAdvancedUpload { + if admin.Username != "admin" || !admin.MustChangePassword || !admin.CanManageUsers || !admin.CanManageFiles || !admin.CanAdvancedUpload { t.Fatalf("unexpected admin: %+v", admin) } if bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) != nil { @@ -113,6 +113,25 @@ func TestBootstrapAdminAndForcePasswordChange(t *testing.T) { } } +func TestEnsureAdminRestoresAllPermissions(t *testing.T) { + app, _ := testApp(t) + if err := app.db.Model(&User{}).Where("id = ?", 1).Updates(map[string]any{ + "can_manage_users": false, "can_manage_files": false, "can_upload": false, "can_advanced_upload": false, + }).Error; err != nil { + t.Fatal(err) + } + if password, err := app.ensureAdmin(); err != nil || password != "" { + t.Fatalf("ensure admin: password = %q, err = %v", password, err) + } + var admin User + if err := app.db.First(&admin, 1).Error; err != nil { + t.Fatal(err) + } + if !admin.CanManageUsers || !admin.CanManageFiles || !admin.CanUpload || !admin.CanAdvancedUpload { + t.Fatalf("admin permissions were not restored: %+v", admin) + } +} + func TestPickupCodePreviewConsumesFinalAccessAndDeletesFile(t *testing.T) { app, _ := testApp(t) hash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost) @@ -295,6 +314,58 @@ func TestCleanupRemovesExpiredFileFromDatabaseAndDisk(t *testing.T) { } } +func TestFileManagementRequiresPermissionAndDeletesFile(t *testing.T) { + app, _ := testApp(t) + passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost) + owner := User{Username: "file-owner", PasswordHash: string(passwordHash), CanUpload: true} + manager := User{Username: "file-manager", PasswordHash: string(passwordHash), CanManageFiles: true} + viewer := User{Username: "no-file-access", PasswordHash: string(passwordHash), CanManageUsers: true} + for _, user := range []*User{&owner, &manager, &viewer} { + if err := app.db.Create(user).Error; err != nil { + t.Fatal(err) + } + } + expires := time.Now().Add(time.Hour) + max := 3 + file := SharedFile{Hash: "managed1", OriginalName: "managed.txt", MimeType: "text/plain", Size: 7, OwnerID: owner.ID, ExpiresAt: &expires, MaxDownloads: &max, Ready: true} + 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("managed"), 0o640); err != nil { + t.Fatal(err) + } + router := app.router() + + viewerCookie := loginAs(t, router, viewer.Username, "password-123") + denied := request(t, router, http.MethodGet, "/api/files", nil, "", viewerCookie) + if denied.Code != http.StatusForbidden { + t.Fatalf("list without permission status = %d", denied.Code) + } + denied = request(t, router, http.MethodDelete, "/api/files/"+jsonNumber(file.ID), nil, "", viewerCookie) + if denied.Code != http.StatusForbidden { + t.Fatalf("delete without permission status = %d", denied.Code) + } + + managerCookie := loginAs(t, router, manager.Username, "password-123") + listed := request(t, router, http.MethodGet, "/api/files", nil, "", managerCookie) + if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), owner.Username) || !strings.Contains(listed.Body.String(), file.Hash) { + t.Fatalf("list files status = %d, body = %s", listed.Code, listed.Body.String()) + } + deleted := request(t, router, http.MethodDelete, "/api/files/"+jsonNumber(file.ID), nil, "", managerCookie) + if deleted.Code != http.StatusNoContent { + t.Fatalf("delete file status = %d, body = %s", deleted.Code, deleted.Body.String()) + } + var count int64 + app.db.Model(&SharedFile{}).Where("id = ?", file.ID).Count(&count) + if count != 0 { + t.Fatal("deleted file record remains") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("deleted file remains on disk") + } +} + func TestBasicUploadLimitAndAdvancedInheritance(t *testing.T) { app, _ := testApp(t) passwordHash, _ := bcrypt.GenerateFromPassword([]byte("password-123"), bcrypt.DefaultCost) @@ -380,6 +451,11 @@ func TestUserManagerCannotGrantOrTakeOverPermissionsTheyDoNotHave(t *testing.T) if created.Code != http.StatusForbidden { t.Fatalf("create advanced user status = %d, body = %s", created.Code, created.Body.String()) } + createBody, _ = json.Marshal(map[string]any{"username": "file-admin", "canManageFiles": true}) + created = request(t, router, http.MethodPost, "/api/users", bytes.NewReader(createBody), "application/json", cookie) + if created.Code != http.StatusForbidden { + t.Fatalf("grant file management status = %d, body = %s", created.Code, created.Body.String()) + } updateBody, _ := json.Marshal(map[string]any{"username": basicUser.Username, "canUpload": true, "canAdvancedUpload": true}) updated := request(t, router, http.MethodPut, "/api/users/"+jsonNumber(basicUser.ID), bytes.NewReader(updateBody), "application/json", cookie) diff --git a/frontend/index.html b/frontend/index.html index da07e2b..079779e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,7 @@ + ufile · 文件共享中心 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..86e3814c1f9f3235e0cc4a156fb492c7c2a3b3a2 GIT binary patch literal 8465 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 LKaKh4)9e2M-qQz< literal 0 HcmV?d00001 diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..24c3c59 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 835d72a..e57b2f8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react' import { - ArrowDownToLine, Check, Copy, Eye, FileArchive, FileUp, + ArrowDownToLine, Check, Copy, Eye, FileArchive, Files, FileUp, KeyRound, LoaderCircle, LogOut, ShieldCheck, - UploadCloud, Users, X, + Trash2, UploadCloud, Users, X, } from 'lucide-react' import { api, json } from './api' import { useAppStore } from './store' @@ -164,7 +164,7 @@ function UploadResult({ file, onAgain }) { function UserModal({ value, onClose, onSaved }) { const actor = useAppStore((s) => s.user) const canGrantUpload = actor.canUpload || actor.canAdvancedUpload - const [form, setForm] = useState(value || { username: '', canManageUsers: false, canUpload: canGrantUpload, canAdvancedUpload: false }) + const [form, setForm] = useState(value || { username: '', canManageUsers: false, canManageFiles: false, canUpload: canGrantUpload, canAdvancedUpload: false }) const [error, setError] = useState('') const submit = async (e) => { e.preventDefault(); setError('') @@ -172,7 +172,7 @@ function UserModal({ value, onClose, onSaved }) { catch (err) { setError(err.message) } } const checkbox = (key, title, hint, disabled = false) => - return

{value ? '编辑用户' : '创建用户'}

setForm({ ...form, username: e.target.value })} />

只能分配你当前拥有的权限。

{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.canAdvancedUpload)}
{error &&

{error}

}
+ return

{value ? '编辑用户' : '创建用户'}

setForm({ ...form, username: e.target.value })} />

只能分配你当前拥有的权限。

{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canManageFiles', '文件管理', '查看并删除所有用户上传的文件', !actor.canManageFiles)}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.canAdvancedUpload)}
{error &&

{error}

}
} function UsersPanel({ notify }) { @@ -184,15 +184,37 @@ function UsersPanel({ notify }) { 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 manageable = (user) => (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload) + const manageable = (user) => (!user.canManageFiles || actor.canManageFiles) && (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload) const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; try { const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) } catch (error) { notify(error.message) } } const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; try { await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') } catch (error) { notify(error.message) } } - return

用户管理

只能管理权限不高于自己的用户

{users.map((user) =>
{user.username.slice(0, 1).toUpperCase()}

{user.username}

#{user.id}{user.mustChangePassword && 待改密}

{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}

{manageable(user) ?
{user.id !== 1 && }
: 权限高于你}
)}
+ return

用户管理

只能管理权限不高于自己的用户

{users.map((user) =>
{user.username.slice(0, 1).toUpperCase()}

{user.username}

#{user.id}{user.mustChangePassword && 待改密}

{[user.canManageUsers && '用户管理', user.canManageFiles && '文件管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}

{manageable(user) ?
{user.id !== 1 && }
: 权限高于你}
)}
{modalOpen && setModalOpen(false)} onSaved={saved} />} {secret &&

{secret.title}

该密码只显示这一次,用户登录后必须修改。

{secret.password}
}
} +function FileManagementPanel({ notify }) { + const files = useAppStore((s) => s.managedFiles) + const loadFiles = useAppStore((s) => s.loadManagedFiles) + const [loading, setLoading] = useState(true) + const refresh = async () => { setLoading(true); try { await loadFiles() } catch (error) { notify(error.message) } finally { setLoading(false) } } + useEffect(() => { refresh() }, []) + const remove = async (file) => { + if (!confirm(`删除文件「${file.name}」?此操作不可撤销。`)) return + try { await api(`/api/files/${file.id}`, { method: 'DELETE' }); await refresh(); notify('文件已删除') } + catch (error) { notify(error.message) } + } + const copy = async (file) => { await navigator.clipboard.writeText(`${location.origin}/uf/${file.hash}`); notify('文件链接已复制') } + return
+

文件管理

全部用户当前有效的文件

+ {loading && files.length === 0 ?
: files.length === 0 ?
当前没有有效文件
:
{files.map((file) =>
+

{file.name}

+
上传者{file.ownerUsername || `#${file.ownerId}`}失效时间{formatDate(file.expiresAt)}访问次数{file.maxDownloads == null ? '无限' : `${file.downloadCount} / ${file.maxDownloads}`}取件码{file.hasPickupCode ? `已设置 · 失败 ${file.failedCodeAttempts}/5` : '未设置'}
+ +
)}
} +
+} + function Dashboard() { const user = useAppStore((s) => s.user) const logout = useAppStore((s) => s.logout) @@ -200,8 +222,8 @@ function Dashboard() { const [toast, setToast] = useState('') const notify = (message) => setToast(message) return
ufile
{user.username}
-
{user.canManageUsers && }
- {tab === 'files' ?
: } +
{user.canManageFiles && }{user.canManageUsers && }
+ {tab === 'files' ?
: tab === 'managed-files' ? : }
{toast && setToast('')} />}
} diff --git a/frontend/src/store.js b/frontend/src/store.js index 9c7af7c..482b051 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -5,6 +5,7 @@ export const useAppStore = create((set) => ({ user: null, checking: true, users: [], + managedFiles: [], async checkSession() { try { set({ user: await api('/api/auth/me') }) } catch { set({ user: null }) } @@ -16,8 +17,9 @@ export const useAppStore = create((set) => ({ }, async logout() { await api('/api/auth/logout', { method: 'POST' }) - set({ user: null, users: [] }) + set({ user: null, users: [], managedFiles: [] }) }, setUser: (user) => set({ user }), async loadUsers() { set({ users: await api('/api/users') }) }, + async loadManagedFiles() { set({ managedFiles: await api('/api/files') }) }, })) diff --git a/models.go b/models.go index 5c87579..36e70e5 100644 --- a/models.go +++ b/models.go @@ -7,6 +7,7 @@ type User struct { Username string `gorm:"uniqueIndex;size:32;not null" json:"username"` PasswordHash string `json:"-"` CanManageUsers bool `json:"canManageUsers"` + CanManageFiles bool `json:"canManageFiles"` CanUpload bool `json:"canUpload"` CanAdvancedUpload bool `json:"canAdvancedUpload"` MustChangePassword bool `json:"mustChangePassword"` @@ -41,22 +42,26 @@ type SharedFile struct { 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"` + 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"` + OwnerID uint `json:"ownerId"` + OwnerUsername string `json:"ownerUsername,omitempty"` + HasPickupCode bool `json:"hasPickupCode"` + FailedCodeAttempts int `json:"failedCodeAttempts"` + 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, + DownloadCount: f.DownloadCount, OwnerID: f.OwnerID, HasPickupCode: f.HasPickupCode(), + FailedCodeAttempts: f.FailedCodeAttempts, CreatedAt: f.CreatedAt, } }