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 0000000..86e3814 Binary files /dev/null and b/frontend/public/favicon.ico differ 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, } }