From 9bf397064d9d5ad4bca9a19f43f06d59843a5b65 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 21 Sep 2026 20:50:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=8E=A5=E5=8F=A3=E4=B8=8E=E7=94=A8=E6=88=B7=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E8=A3=81=E5=89=AA=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 storage 配置(存储目录、单文件大小上限),ConfigVersion 3→4 自动补全 - internal/file:上传(sha256 秒传去重)、删除(上传者或管理员,引用中 409)、公开查看;本地存储 + 操作日志 + 引用计数,仅安全类型内联防存储型 XSS - internal/avatar:PUT/DELETE /api/me/avatar,自动管理头像文件引用与旧头像解绑 - 前端引入 vue-advanced-cropper,个人中心支持上传/更换/删除头像,裁剪输出 512×512 JPEG;http 请求支持 FormData - 导出 auth.CurrentUser、新增 model.User.IsAdmin 与 testutil 多部件上传辅助,补充接口测试并重新生成 Swagger 文档 --- docs/backend-development.md | 4 +- docs/docs.go | 339 ++++++++++++++++++ docs/swagger.json | 339 ++++++++++++++++++ docs/swagger.yaml | 228 ++++++++++++ frontend/package-lock.json | 37 ++ frontend/package.json | 1 + frontend/src/api/avatar.ts | 15 + frontend/src/api/http.ts | 2 +- .../components/profile/AvatarCropDialog.vue | 121 +++++++ frontend/src/i18n/locales/en-US.ts | 20 ++ frontend/src/i18n/locales/ja-JP.ts | 20 ++ frontend/src/i18n/locales/zh-CN.ts | 20 ++ frontend/src/stores/auth.ts | 15 + frontend/src/views/ProfileView.vue | 169 ++++++++- internal/api/api.go | 9 +- internal/auth/auth.go | 15 +- internal/auth/profile.go | 4 +- internal/avatar/avatar.go | 144 ++++++++ internal/avatar/avatar_test.go | 159 ++++++++ internal/config/config.default.yaml | 6 +- internal/config/config.go | 29 ++ internal/config/upgrade.go | 2 +- internal/file/file.go | 332 +++++++++++++++++ internal/file/file_test.go | 218 +++++++++++ internal/file/handler.go | 202 +++++++++++ internal/model/user.go | 10 + internal/testutil/testutil.go | 49 +++ 27 files changed, 2478 insertions(+), 31 deletions(-) create mode 100644 frontend/src/api/avatar.ts create mode 100644 frontend/src/components/profile/AvatarCropDialog.vue create mode 100644 internal/avatar/avatar.go create mode 100644 internal/avatar/avatar_test.go create mode 100644 internal/file/file.go create mode 100644 internal/file/file_test.go create mode 100644 internal/file/handler.go diff --git a/docs/backend-development.md b/docs/backend-development.md index f6417b1..3a03fc3 100644 --- a/docs/backend-development.md +++ b/docs/backend-development.md @@ -14,6 +14,8 @@ internal/ ├── usergroup/ 用户组管理 ├── note/ 便签 ├── site/ 站点信息 +├── file/ 文件上传、删除、查看与本地存储 +├── avatar/ 当前用户头像 ├── httpx/ HTTP 公共能力:ErrorResponse、分页、ID 解析 ├── utils/ 通用工具(与业务无关的公共函数) ├── model/ 数据库模型 @@ -84,7 +86,7 @@ password, err := utils.RandomString(16) - **响应文案一律英文**,统一错误响应 `httpx.ErrorResponse`,如 `invalid request`、`record not found`、`internal server error`、`unauthorized or session expired` - 分页参数用 `httpx.ParsePagination(c)`;路径 ID 用 `httpx.ParseID(c)`(用户组用 `usergroup` 内部的 `parseGroupID`,允许 id 0) - Swagger 注解:`@Summary` / `@Description` / `@Tags`(按权限取 `public` / `user` / `admin`)/ `@Security BearerAuth` / 401、403、500 失败响应;注解修改后执行 `go generate ./...` 重新生成 `docs/` -- 路由按权限挂载:公开(`/health`、`/swagger`、`/auth/*`、`GET /site`)、需登录、仅管理员 +- 路由按权限挂载:公开(`/health`、`/swagger`、`/auth/*`、`GET /site`、`GET /files/:id`)、需登录(个人资料、头像、文件上传删除、notes)、仅管理员 ## 4. 数据库与迁移 diff --git a/docs/docs.go b/docs/docs.go index 635d96e..d6e0e8b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -125,6 +125,187 @@ const docTemplate = `{ } } }, + "/files": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload a file (multipart field file). Content is deduplicated by sha256; the returned file has ref_count 0 until a business reference is acquired. Size limit from storage.max_size_mb.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Upload a file", + "parameters": [ + { + "type": "file", + "description": "File content", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.File" + } + }, + "400": { + "description": "invalid request or empty file", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "413": { + "description": "file too large", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/files/{id}": { + "get": { + "description": "Public file content. Images, videos, audio, PDF and plain text are served inline; other types are served as attachments. Disabled files return 404.", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "public" + ], + "summary": "Get file content", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a file physically and keep the record with status 0; uploader or admin only. Files still referenced (ref_count \u003e 0) return 409.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a file", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "permission denied or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "file is in use", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "Check service and database connectivity; returns 503 when the database is unavailable.", @@ -249,6 +430,114 @@ const docTemplate = `{ } } }, + "/me/avatar": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload an image as the authenticated user's avatar (multipart field file, image only). The avatar URL is stored on the user and the file reference count is managed automatically.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update current user avatar", + "parameters": [ + { + "type": "file", + "description": "Avatar image", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "invalid request, empty file, or not an image", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "413": { + "description": "file too large", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Clear the authenticated user's avatar and release the file reference when it points to a local file.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete current user avatar", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, "/notes": { "get": { "security": [ @@ -1422,6 +1711,56 @@ const docTemplate = `{ } } }, + "model.File": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_referenced_at": { + "type": "string" + }, + "metadata": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "ref_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "storage": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "uploader_id": { + "type": "integer" + } + } + }, "model.Note": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 452ebc6..a2e8b94 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -118,6 +118,187 @@ } } }, + "/files": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload a file (multipart field file). Content is deduplicated by sha256; the returned file has ref_count 0 until a business reference is acquired. Size limit from storage.max_size_mb.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Upload a file", + "parameters": [ + { + "type": "file", + "description": "File content", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.File" + } + }, + "400": { + "description": "invalid request or empty file", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "413": { + "description": "file too large", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/files/{id}": { + "get": { + "description": "Public file content. Images, videos, audio, PDF and plain text are served inline; other types are served as attachments. Disabled files return 404.", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "public" + ], + "summary": "Get file content", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "file" + } + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a file physically and keep the record with status 0; uploader or admin only. Files still referenced (ref_count \u003e 0) return 409.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete a file", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "permission denied or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "file is in use", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, "/health": { "get": { "description": "Check service and database connectivity; returns 503 when the database is unavailable.", @@ -242,6 +423,114 @@ } } }, + "/me/avatar": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload an image as the authenticated user's avatar (multipart field file, image only). The avatar URL is stored on the user and the file reference count is managed automatically.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update current user avatar", + "parameters": [ + { + "type": "file", + "description": "Avatar image", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "400": { + "description": "invalid request, empty file, or not an image", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "413": { + "description": "file too large", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Clear the authenticated user's avatar and release the file reference when it points to a local file.", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete current user avatar", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.User" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, "/notes": { "get": { "security": [ @@ -1415,6 +1704,56 @@ } } }, + "model.File": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "extension": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_referenced_at": { + "type": "string" + }, + "metadata": { + "type": "string" + }, + "mime_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "ref_count": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "storage": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "uploader_id": { + "type": "integer" + } + } + }, "model.Note": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 97fa4aa..d3b5c2d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -72,6 +72,39 @@ definitions: example: record not found type: string type: object + model.File: + properties: + created_at: + type: string + extension: + type: string + hash: + type: string + id: + type: integer + last_referenced_at: + type: string + metadata: + type: string + mime_type: + type: string + name: + type: string + path: + type: string + ref_count: + type: integer + size: + type: integer + status: + type: integer + storage: + type: string + updated_at: + type: string + uploader_id: + type: integer + type: object model.Note: properties: content: @@ -405,6 +438,129 @@ paths: summary: Register tags: - public + /files: + post: + consumes: + - multipart/form-data + description: Upload a file (multipart field file). Content is deduplicated by + sha256; the returned file has ref_count 0 until a business reference is acquired. + Size limit from storage.max_size_mb. + parameters: + - description: File content + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.File' + "400": + description: invalid request or empty file + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "413": + description: file too large + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Upload a file + tags: + - user + /files/{id}: + delete: + description: Delete a file physically and keep the record with status 0; uploader + or admin only. Files still referenced (ref_count > 0) return 409. + parameters: + - description: File ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: Deleted + "400": + description: invalid id + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: permission denied or account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "404": + description: record not found + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "409": + description: file is in use + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a file + tags: + - user + get: + description: Public file content. Images, videos, audio, PDF and plain text + are served inline; other types are served as attachments. Disabled files return + 404. + parameters: + - description: File ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/octet-stream + responses: + "200": + description: OK + schema: + type: file + "400": + description: invalid id + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "404": + description: record not found + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + summary: Get file content + tags: + - public /health: get: description: Check service and database connectivity; returns 503 when the database @@ -487,6 +643,78 @@ paths: summary: Update current user profile tags: - user + /me/avatar: + delete: + description: Clear the authenticated user's avatar and release the file reference + when it points to a local file. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete current user avatar + tags: + - user + put: + consumes: + - multipart/form-data + description: Upload an image as the authenticated user's avatar (multipart field + file, image only). The avatar URL is stored on the user and the file reference + count is managed automatically. + parameters: + - description: Avatar image + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.User' + "400": + description: invalid request, empty file, or not an image + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "413": + description: file too large + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Update current user avatar + tags: + - user /notes: get: description: List notes ordered by id DESC. page starts at 1; page_size is 1-100, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ecaace..d574e63 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "tailwindcss": "^4.3.3", "vee-validate": "^4.15.1", "vue": "^3.5.38", + "vue-advanced-cropper": "^2.8.9", "vue-i18n": "^11.4.12", "vue-router": "^5.1.0", "zod": "^3.25.76" @@ -1728,6 +1729,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -1800,6 +1807,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1870,6 +1883,12 @@ "node": ">=8" } }, + "node_modules/easy-bem": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz", + "integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.430", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", @@ -3387,6 +3406,24 @@ } } }, + "node_modules/vue-advanced-cropper": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/vue-advanced-cropper/-/vue-advanced-cropper-2.8.9.tgz", + "integrity": "sha512-1jc5gO674kVGpJKekoaol6ZlwaF5VYDLSBwBOUpViW0IOrrRsyLw6XNszjEqgbavvqinlKNS6Kqlom3B5M72Tw==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.6", + "debounce": "^1.2.0", + "easy-bem": "^1.0.2" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vue-i18n": { "version": "11.4.12", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index cbc6962..164ec5e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "tailwindcss": "^4.3.3", "vee-validate": "^4.15.1", "vue": "^3.5.38", + "vue-advanced-cropper": "^2.8.9", "vue-i18n": "^11.4.12", "vue-router": "^5.1.0", "zod": "^3.25.76" diff --git a/frontend/src/api/avatar.ts b/frontend/src/api/avatar.ts new file mode 100644 index 0000000..1e305c8 --- /dev/null +++ b/frontend/src/api/avatar.ts @@ -0,0 +1,15 @@ +import { request } from './http' +import type { AuthUser } from './auth' + +export function uploadAvatar(file: Blob, filename = 'avatar.jpg'): Promise { + const form = new FormData() + form.append('file', file, filename) + return request('/me/avatar', { + method: 'PUT', + body: form, + }) +} + +export function deleteAvatar(): Promise { + return request('/me/avatar', { method: 'DELETE' }) +} diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 4ff5207..86049ee 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -21,7 +21,7 @@ export function setUnauthorizedHandler(handler: (() => void) | null) { export async function request(path: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers) - if (init?.body && !headers.has('Content-Type')) { + if (init?.body && !(init.body instanceof FormData) && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json') } if (authToken) { diff --git a/frontend/src/components/profile/AvatarCropDialog.vue b/frontend/src/components/profile/AvatarCropDialog.vue new file mode 100644 index 0000000..267defa --- /dev/null +++ b/frontend/src/components/profile/AvatarCropDialog.vue @@ -0,0 +1,121 @@ + + + diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index a35a3c4..57f6f28 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -111,6 +111,26 @@ const enUS: MessageSchema = { birthday: 'Birthday', save: 'Save changes', saveSuccess: 'Your profile has been updated', + avatar: { + upload: 'Upload avatar', + change: 'Change avatar', + remove: 'Remove avatar', + cropTitle: 'Crop avatar', + cropHint: 'Drag to reposition, use the wheel or buttons to zoom', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + reset: 'Reset', + confirm: 'Confirm', + cancel: 'Cancel', + changeSuccess: 'Avatar updated', + removeSuccess: 'Avatar removed', + errors: { + type: 'Please choose an image file', + size: 'Image size must not exceed {size} MB', + invalid: 'The image is not valid, please choose another one', + upload: 'Failed to update the avatar, please try again later', + }, + }, errors: { nicknameLength: 'Nickname must be at most 50 characters', birthdayInvalid: 'Invalid birthday format', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 6a7dafe..9ba68e0 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -111,6 +111,26 @@ const jaJP: MessageSchema = { birthday: '誕生日', save: '変更を保存', saveSuccess: 'プロフィールを更新しました', + avatar: { + upload: 'アイコンをアップロード', + change: 'アイコンを変更', + remove: 'アイコンを削除', + cropTitle: 'アイコンをトリミング', + cropHint: 'ドラッグで位置を調整し、ホイールまたはボタンで拡大縮小します', + zoomIn: '拡大', + zoomOut: '縮小', + reset: 'リセット', + confirm: '決定', + cancel: 'キャンセル', + changeSuccess: 'アイコンを更新しました', + removeSuccess: 'アイコンを削除しました', + errors: { + type: '画像ファイルを選択してください', + size: '画像サイズは {size} MB 以内にしてください', + invalid: '画像が要件を満たしていません。別の画像を選択してください', + upload: 'アイコンの更新に失敗しました。後でもう一度お試しください', + }, + }, errors: { nicknameLength: 'ニックネームは 50 文字以内で入力してください', birthdayInvalid: '誕生日の形式が正しくありません', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 875bef0..10604c5 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -109,6 +109,26 @@ const zhCN = { birthday: '生日', save: '保存修改', saveSuccess: '个人资料已更新', + avatar: { + upload: '上传头像', + change: '更换头像', + remove: '删除头像', + cropTitle: '裁剪头像', + cropHint: '拖动调整位置,使用滚轮或按钮缩放', + zoomIn: '放大', + zoomOut: '缩小', + reset: '重置', + confirm: '确认', + cancel: '取消', + changeSuccess: '头像已更新', + removeSuccess: '头像已删除', + errors: { + type: '请选择图片文件', + size: '图片大小不能超过 {size} MB', + invalid: '图片不符合要求,请重新选择', + upload: '头像上传失败,请稍后重试', + }, + }, errors: { nicknameLength: '昵称最多 50 个字符', birthdayInvalid: '生日格式不正确', diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 934818e..deb445e 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -12,6 +12,7 @@ import { updateProfile as updateProfileRequest, type UpdateProfilePayload, } from '@/api/profile' +import { deleteAvatar as deleteAvatarRequest, uploadAvatar as uploadAvatarRequest } from '@/api/avatar' import { setAuthToken } from '@/api/http' const TOKEN_KEY = 'rill-token' @@ -96,6 +97,18 @@ export const useAuthStore = defineStore('auth', () => { return data } + async function updateAvatar(file: Blob): Promise { + const data = await uploadAvatarRequest(file) + applyUser(data) + return data + } + + async function deleteAvatar(): Promise { + const data = await deleteAvatarRequest() + applyUser(data) + return data + } + function logout() { clear() } @@ -120,6 +133,8 @@ export const useAuthStore = defineStore('auth', () => { register, updateProfile, refreshProfile, + updateAvatar, + deleteAvatar, logout, restore, } diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 9bb6aa7..2422216 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -5,6 +5,7 @@ import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import { z } from 'zod' import { ApiError } from '@/api/http' +import AvatarCropDialog from '@/components/profile/AvatarCropDialog.vue' import { useAuthStore } from '@/stores/auth' interface ProfileForm { @@ -57,6 +58,91 @@ const [birthday, birthdayProps] = defineField('birthday') const submitError = ref('') const saved = ref(false) +const avatarInput = ref(null) +const cropOpen = ref(false) +const cropSource = ref(null) +const avatarBusy = ref(false) +const avatarError = ref('') +const avatarNotice = ref('') + +const MAX_AVATAR_MB = 10 + +function resetAvatarInput() { + if (avatarInput.value) { + avatarInput.value.value = '' + } +} + +function releaseCropSource() { + if (cropSource.value) { + URL.revokeObjectURL(cropSource.value) + cropSource.value = null + } +} + +function closeCrop() { + cropOpen.value = false + avatarBusy.value = false + releaseCropSource() + resetAvatarInput() +} + +function onAvatarSelected(event: Event) { + avatarError.value = '' + avatarNotice.value = '' + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file) { + return + } + if (!file.type.startsWith('image/')) { + avatarError.value = t('profile.avatar.errors.type') + resetAvatarInput() + return + } + if (file.size > MAX_AVATAR_MB * 1024 * 1024) { + avatarError.value = t('profile.avatar.errors.size', { size: MAX_AVATAR_MB }) + resetAvatarInput() + return + } + cropSource.value = URL.createObjectURL(file) + cropOpen.value = true +} + +async function onAvatarConfirm(blob: Blob) { + avatarBusy.value = true + avatarError.value = '' + try { + await auth.updateAvatar(blob) + avatarNotice.value = t('profile.avatar.changeSuccess') + closeCrop() + } catch (error) { + avatarBusy.value = false + cropOpen.value = false + releaseCropSource() + resetAvatarInput() + if (error instanceof ApiError && (error.status === 400 || error.status === 413)) { + avatarError.value = t('profile.avatar.errors.invalid') + } else { + avatarError.value = t('profile.avatar.errors.upload') + } + } +} + +async function onAvatarDelete() { + avatarBusy.value = true + avatarError.value = '' + avatarNotice.value = '' + try { + await auth.deleteAvatar() + avatarNotice.value = t('profile.avatar.removeSuccess') + } catch { + avatarError.value = t('profile.avatar.errors.upload') + } finally { + avatarBusy.value = false + } +} + const avatarInitial = computed(() => auth.displayName.slice(0, 1).toUpperCase()) onMounted(async () => { @@ -95,23 +181,66 @@ const onSubmit = handleSubmit(async (values) => { diff --git a/internal/api/api.go b/internal/api/api.go index ab1eeb9..28b5970 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -11,15 +11,17 @@ import ( "gorm.io/gorm" "rill/internal/auth" + "rill/internal/avatar" "rill/internal/config" "rill/internal/database" + "rill/internal/file" "rill/internal/note" "rill/internal/site" "rill/internal/user" "rill/internal/usergroup" ) -// RegisterRoutes 注册 API 路由。health、swagger、auth 与站点信息读取公开;notes 与个人资料需登录;站点信息更新、用户与用户组管理仅限管理员。 +// RegisterRoutes 注册 API 路由。health、swagger、auth、站点信息与文件查看公开;notes、个人资料与文件上传删除需登录;站点信息更新、用户与用户组管理仅限管理员。 func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) { authn := auth.NewAuthenticator(cfg) @@ -40,11 +42,16 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) { } rg.GET("/site", site.Get(db)) + rg.GET("/files/:id", file.View(db, cfg)) authed := rg.Group("", authn.RequireAuth(db)) { authed.GET("/me", auth.Me()) authed.PUT("/me", auth.UpdateMe(db)) + authed.PUT("/me/avatar", avatar.Update(db, cfg)) + authed.DELETE("/me/avatar", avatar.Delete(db, cfg)) + authed.POST("/files", file.Upload(db, cfg)) + authed.DELETE("/files/:id", file.Delete(db, cfg)) notes := authed.Group("/notes") { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 5feb0de..7a590e6 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -277,22 +277,21 @@ func (a *Authenticator) RequireAuth(db *gorm.DB) gin.HandlerFunc { // RequireAdmin 要求当前用户属于 admin 组,需在 RequireAuth 之后使用。 func RequireAdmin() gin.HandlerFunc { return func(c *gin.Context) { - current, ok := currentUser(c) + current, ok := CurrentUser(c) if !ok { httpx.RespondUnauthorized(c) return } - for _, group := range current.Groups { - if group.ID == model.GroupIDAdmin { - c.Next() - return - } + if !current.IsAdmin() { + c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "admin permission required"}) + return } - c.AbortWithStatusJSON(http.StatusForbidden, httpx.ErrorResponse{Error: "admin permission required"}) + c.Next() } } -func currentUser(c *gin.Context) (model.User, bool) { +// CurrentUser 读取 RequireAuth 写入的当前登录用户。 +func CurrentUser(c *gin.Context) (model.User, bool) { value, ok := c.Get(authUserKey) if !ok { return model.User{}, false diff --git a/internal/auth/profile.go b/internal/auth/profile.go index f1cb105..4bdc82a 100644 --- a/internal/auth/profile.go +++ b/internal/auth/profile.go @@ -28,7 +28,7 @@ type UpdateProfileRequest struct { // @Router /me [get] func Me() gin.HandlerFunc { return func(c *gin.Context) { - current, ok := currentUser(c) + current, ok := CurrentUser(c) if !ok { httpx.RespondUnauthorized(c) return @@ -58,7 +58,7 @@ func UpdateMe(db *gorm.DB) gin.HandlerFunc { return } - current, ok := currentUser(c) + current, ok := CurrentUser(c) if !ok { httpx.RespondUnauthorized(c) return diff --git a/internal/avatar/avatar.go b/internal/avatar/avatar.go new file mode 100644 index 0000000..f1ccf2e --- /dev/null +++ b/internal/avatar/avatar.go @@ -0,0 +1,144 @@ +// Package avatar 提供当前用户头像的上传与删除。 +package avatar + +import ( + "errors" + "io" + "mime/multipart" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "rill/internal/auth" + "rill/internal/config" + "rill/internal/file" + "rill/internal/httpx" + "rill/internal/model" +) + +// @Summary Update current user avatar +// @Description Upload an image as the authenticated user's avatar (multipart field file, image only). The avatar URL is stored on the user and the file reference count is managed automatically. +// @Tags user +// @Accept mpfd +// @Produce json +// @Param file formData file true "Avatar image" +// @Success 200 {object} model.User +// @Failure 400 {object} httpx.ErrorResponse "invalid request, empty file, or not an image" +// @Failure 413 {object} httpx.ErrorResponse "file too large" +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" +// @Failure 403 {object} httpx.ErrorResponse "account disabled" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /me/avatar [put] +func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + current, ok := auth.CurrentUser(c) + if !ok { + httpx.RespondUnauthorized(c) + return + } + header, ok := file.ReadUpload(c, cfg) + if !ok { + return + } + if isImage, err := isImageUpload(header); err != nil { + httpx.RespondServerError(c, err, "读取上传图片失败") + return + } else if !isImage { + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "avatar must be an image"}) + return + } + src, err := header.Open() + if err != nil { + httpx.RespondServerError(c, err, "打开上传图片失败") + return + } + defer src.Close() + + ctx := c.Request.Context() + saved, err := file.Save(ctx, db, cfg, file.OperatorOf(c, current), header.Filename, src) + if err != nil { + file.RespondSaveError(c, err) + return + } + + oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, current.Avatar) + avatarURL := file.URL(cfg.API.Prefix, saved.ID) + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if !hasOld || oldID != saved.ID { + if err := file.Acquire(ctx, tx, saved.ID); err != nil { + return err + } + } + if hasOld && oldID != saved.ID { + if err := file.Release(ctx, tx, oldID); err != nil { + return err + } + } + return tx.Model(&model.User{}).Where("id = ?", current.ID).Update("avatar", avatarURL).Error + }) + if err != nil { + httpx.RespondDBError(c, err) + return + } + + current.Avatar = avatarURL + c.JSON(http.StatusOK, current) + } +} + +// @Summary Delete current user avatar +// @Description Clear the authenticated user's avatar and release the file reference when it points to a local file. +// @Tags user +// @Produce json +// @Success 200 {object} model.User +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" +// @Failure 403 {object} httpx.ErrorResponse "account disabled" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /me/avatar [delete] +func Delete(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + current, ok := auth.CurrentUser(c) + if !ok { + httpx.RespondUnauthorized(c) + return + } + + ctx := c.Request.Context() + oldID, hasOld := file.ParseLocalURL(cfg.API.Prefix, current.Avatar) + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if hasOld { + if err := file.Release(ctx, tx, oldID); err != nil { + return err + } + } + return tx.Model(&model.User{}).Where("id = ?", current.ID).Update("avatar", "").Error + }) + if err != nil { + httpx.RespondDBError(c, err) + return + } + + current.Avatar = "" + c.JSON(http.StatusOK, current) + } +} + +// isImageUpload 通过文件头探测是否为图片,避免仅信任客户端声明的类型。 +func isImageUpload(header *multipart.FileHeader) (bool, error) { + src, err := header.Open() + if err != nil { + return false, err + } + defer src.Close() + + head := make([]byte, 512) + n, err := src.Read(head) + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + return strings.HasPrefix(http.DetectContentType(head[:n]), "image/"), nil +} diff --git a/internal/avatar/avatar_test.go b/internal/avatar/avatar_test.go new file mode 100644 index 0000000..1f9d0cb --- /dev/null +++ b/internal/avatar/avatar_test.go @@ -0,0 +1,159 @@ +package avatar_test + +import ( + "net/http" + "strconv" + "strings" + "testing" + + "rill/internal/model" + "rill/internal/testutil" +) + +func registerUser(t *testing.T, env *testutil.Env, username string) model.User { + t.Helper() + w := testutil.Call(t, env.Router(""), http.MethodPost, "/api/auth/register", map[string]string{ + "username": username, "email": username + "@example.com", "password": "secret123", + }) + if w.Code != http.StatusCreated { + t.Fatalf("注册 %s 失败: %d, body=%s", username, w.Code, w.Body.String()) + } + return testutil.DecodeUser(t, w) +} + +func avatarFileID(t *testing.T, avatarURL string) uint { + t.Helper() + const prefix = "/api/files/" + if !strings.HasPrefix(avatarURL, prefix) { + t.Fatalf("头像地址格式异常: %q", avatarURL) + } + id, err := strconv.ParseUint(strings.TrimPrefix(avatarURL, prefix), 10, 64) + if err != nil || id == 0 { + t.Fatalf("头像文件 ID 解析失败: %q", avatarURL) + } + return uint(id) +} + +func fileRefCount(t *testing.T, env *testutil.Env, id uint) int64 { + t.Helper() + var record model.File + if err := env.DB.First(&record, id).Error; err != nil { + t.Fatalf("查询文件 %d 失败: %v", id, err) + } + return record.RefCount +} + +func TestAvatarLifecycle(t *testing.T) { + env := testutil.Setup(t) + user := registerUser(t, env, "avataruser") + authed := env.Router(env.Sign(user.ID)) + first := testutil.PNG(t, 8, 8) + + if w := testutil.CallMultipart(t, env.Router(""), http.MethodPut, "/api/me/avatar", "file", "a.png", first); w.Code != http.StatusUnauthorized { + t.Errorf("匿名上传头像状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + if w := testutil.CallMultipart(t, authed, http.MethodPut, "/api/me/avatar", "file", "a.txt", []byte("not an image")); w.Code != http.StatusBadRequest { + t.Errorf("非图片上传状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String()) + } + + w := testutil.CallMultipart(t, authed, http.MethodPut, "/api/me/avatar", "file", "avatar.png", first) + if w.Code != http.StatusOK { + t.Fatalf("上传头像状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + updated := testutil.DecodeUser(t, w) + firstID := avatarFileID(t, updated.Avatar) + if got := fileRefCount(t, env, firstID); got != 1 { + t.Fatalf("头像文件引用计数 = %d, 期望 1", got) + } + + // 头像地址公开可访问。 + w = testutil.Call(t, env.Router(""), http.MethodGet, updated.Avatar, nil) + if w.Code != http.StatusOK || !strings.HasPrefix(w.Header().Get("Content-Type"), "image/png") { + t.Fatalf("查看头像状态码 = %d, Content-Type = %q", w.Code, w.Header().Get("Content-Type")) + } + + // 相同图片重复上传:秒传复用,引用计数不重复增加。 + w = testutil.CallMultipart(t, authed, http.MethodPut, "/api/me/avatar", "file", "same.png", first) + if w.Code != http.StatusOK { + t.Fatalf("重复上传状态码 = %d, body=%s", w.Code, w.Body.String()) + } + if again := testutil.DecodeUser(t, w); again.Avatar != updated.Avatar { + t.Errorf("相同图片应复用文件: %q != %q", again.Avatar, updated.Avatar) + } + if got := fileRefCount(t, env, firstID); got != 1 { + t.Errorf("重复上传后引用计数 = %d, 期望 1", got) + } + + // 更换头像:旧文件释放引用,新文件引用为 1。 + second := testutil.PNG(t, 12, 12) + w = testutil.CallMultipart(t, authed, http.MethodPut, "/api/me/avatar", "file", "new.png", second) + if w.Code != http.StatusOK { + t.Fatalf("更换头像状态码 = %d, body=%s", w.Code, w.Body.String()) + } + replaced := testutil.DecodeUser(t, w) + secondID := avatarFileID(t, replaced.Avatar) + if secondID == firstID { + t.Fatal("更换头像应生成新文件") + } + if got := fileRefCount(t, env, firstID); got != 0 { + t.Errorf("旧头像引用计数 = %d, 期望 0", got) + } + if got := fileRefCount(t, env, secondID); got != 1 { + t.Errorf("新头像引用计数 = %d, 期望 1", got) + } + + // /me 返回最新头像。 + w = testutil.Call(t, authed, http.MethodGet, "/api/me", nil) + if me := testutil.DecodeUser(t, w); me.Avatar != replaced.Avatar { + t.Errorf("/me 头像 = %q, 期望 %q", me.Avatar, replaced.Avatar) + } + + // 删除头像:清空字段并释放引用。 + w = testutil.Call(t, authed, http.MethodDelete, "/api/me/avatar", nil) + if w.Code != http.StatusOK { + t.Fatalf("删除头像状态码 = %d, body=%s", w.Code, w.Body.String()) + } + if cleared := testutil.DecodeUser(t, w); cleared.Avatar != "" { + t.Errorf("删除后头像 = %q, 期望空", cleared.Avatar) + } + if got := fileRefCount(t, env, secondID); got != 0 { + t.Errorf("删除后引用计数 = %d, 期望 0", got) + } + + // 无头像时删除保持幂等。 + if w := testutil.Call(t, authed, http.MethodDelete, "/api/me/avatar", nil); w.Code != http.StatusOK { + t.Errorf("重复删除状态码 = %d, 期望 %d", w.Code, http.StatusOK) + } + + // 外链头像直接清空,不影响文件记录。 + if err := env.DB.Model(&model.User{}).Where("id = ?", user.ID). + Update("avatar", "https://example.com/avatar.png").Error; err != nil { + t.Fatalf("设置外链头像失败: %v", err) + } + w = testutil.Call(t, authed, http.MethodDelete, "/api/me/avatar", nil) + if w.Code != http.StatusOK || testutil.DecodeUser(t, w).Avatar != "" { + t.Errorf("外链头像删除异常: %d %s", w.Code, w.Body.String()) + } +} + +func TestAvatarSizeLimit(t *testing.T) { + env := testutil.Setup(t) + user := registerUser(t, env, "avataruser") + authed := env.Router(env.Sign(user.ID)) + + tooLarge := make([]byte, env.Cfg.MaxUploadBytes()+1) + copy(tooLarge, testutil.PNG(t, 2, 2)) + w := testutil.CallMultipart(t, authed, http.MethodPut, "/api/me/avatar", "file", "big.png", tooLarge) + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("超限头像状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusRequestEntityTooLarge, w.Body.String()) + } + + var count int64 + if err := env.DB.Model(&model.File{}).Count(&count).Error; err != nil { + t.Fatalf("统计文件失败: %v", err) + } + if count != 0 { + t.Errorf("超限上传不应产生文件记录: count=%d", count) + } +} diff --git a/internal/config/config.default.yaml b/internal/config/config.default.yaml index 5357b5c..08740f4 100644 --- a/internal/config/config.default.yaml +++ b/internal/config/config.default.yaml @@ -1,5 +1,5 @@ # rill 服务端配置 -version: 3 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 +version: 4 # 配置版本,用于启动时自动补全缺失项,请勿手动修改 server: host: "0.0.0.0" # 监听地址,0.0.0.0 表示所有网卡 @@ -16,6 +16,10 @@ log: static: dir: "./dist" # 前端构建产物目录 +storage: + dir: "./data/uploads" # 上传文件存储根目录 + max_size_mb: 10 # 单文件大小上限(MB) + api: prefix: "/api" # API 路由前缀 cors: diff --git a/internal/config/config.go b/internal/config/config.go index 3f0fed2..ba905e1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { Server ServerConfig `yaml:"server"` Log LogConfig `yaml:"log"` Static StaticConfig `yaml:"static"` + Storage StorageConfig `yaml:"storage"` API APIConfig `yaml:"api"` Auth AuthConfig `yaml:"auth"` Database DatabaseConfig `yaml:"database"` @@ -55,6 +56,17 @@ type StaticConfig struct { Dir string `yaml:"dir"` } +// StorageConfig 上传文件存储配置。 +type StorageConfig struct { + Dir string `yaml:"dir"` // 存储根目录 + MaxSizeMB int `yaml:"max_size_mb"` // 单文件大小上限(MB) +} + +// MaxUploadBytes 单文件大小上限(字节)。 +func (c *Config) MaxUploadBytes() int64 { + return int64(c.Storage.MaxSizeMB) << 20 +} + type APIConfig struct { Prefix string `yaml:"prefix"` CORS CORSConfig `yaml:"cors"` @@ -113,6 +125,10 @@ func defaultConfig() *Config { Static: StaticConfig{ Dir: "./dist", }, + Storage: StorageConfig{ + Dir: "./data/uploads", + MaxSizeMB: 10, + }, API: APIConfig{ Prefix: "/api", CORS: CORSConfig{ @@ -218,12 +234,25 @@ func (c *Config) validate() error { if _, err := time.ParseDuration(c.Auth.TokenTTL); err != nil { return fmt.Errorf("auth.token_ttl 无效: %w", err) } + if err := c.validateStorage(); err != nil { + return err + } if err := c.validateDatabase(); err != nil { return err } return nil } +func (c *Config) validateStorage() error { + if strings.TrimSpace(c.Storage.Dir) == "" { + return fmt.Errorf("storage.dir 不能为空") + } + if c.Storage.MaxSizeMB < 1 || c.Storage.MaxSizeMB > 1024 { + return fmt.Errorf("storage.max_size_mb 无效: %d(可选范围: 1-1024)", c.Storage.MaxSizeMB) + } + return nil +} + func (c *Config) validateDatabase() error { if _, err := time.ParseDuration(c.Database.ConnectTimeout); err != nil { return fmt.Errorf("database.connect_timeout 无效: %w", err) diff --git a/internal/config/upgrade.go b/internal/config/upgrade.go index 4adbfed..7257ca7 100644 --- a/internal/config/upgrade.go +++ b/internal/config/upgrade.go @@ -15,7 +15,7 @@ import ( ) // ConfigVersion 当前配置结构版本,新增配置项时递增。 -const ConfigVersion = 3 +const ConfigVersion = 4 // upgradeResult 描述一次配置自动补全的结果。 type upgradeResult struct { diff --git a/internal/file/file.go b/internal/file/file.go new file mode 100644 index 0000000..98b0aa3 --- /dev/null +++ b/internal/file/file.go @@ -0,0 +1,332 @@ +// Package file 提供文件的上传、删除、查看接口与本地存储服务。 +package file + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + + "rill/internal/config" + "rill/internal/model" +) + +const ( + storageLocal = "local" + tempPrefix = ".upload-" + // viewCacheControl 文件名由内容哈希决定,内容不会变化,可长缓存。 + viewCacheControl = "public, max-age=31536000, immutable" +) + +// ErrTooLarge 上传内容超过大小限制。 +var ErrTooLarge = errors.New("file too large") + +// ErrEmpty 上传内容为空。 +var ErrEmpty = errors.New("empty file") + +// ErrFileNotFound 文件记录不存在或已禁用。 +var ErrFileNotFound = errors.New("file not found") + +// ErrFileInUse 文件仍被业务引用,不允许删除。 +var ErrFileInUse = errors.New("file is in use") + +// Operator 操作人快照,用于写文件操作日志。 +type Operator struct { + ID *uint + Name string + IP string +} + +// Save 保存上传内容并按 sha256 去重:命中已有记录时直接复用,不写日志。 +// 返回记录不增加引用计数,业务引用请调用 Acquire。 +func Save(ctx context.Context, db *gorm.DB, cfg *config.Config, operator Operator, filename string, src io.Reader) (*model.File, error) { + root := cfg.Storage.Dir + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("创建存储目录失败: %w", err) + } + + tmp, err := os.CreateTemp(root, tempPrefix+"*") + if err != nil { + return nil, fmt.Errorf("创建临时文件失败: %w", err) + } + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + }() + + hasher := sha256.New() + limit := cfg.MaxUploadBytes() + size, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(src, limit+1)) + if err != nil { + return nil, fmt.Errorf("写入上传内容失败: %w", err) + } + if size > limit { + return nil, ErrTooLarge + } + if size == 0 { + return nil, ErrEmpty + } + + hash := hex.EncodeToString(hasher.Sum(nil)) + var existing model.File + if err := db.WithContext(ctx).Where("hash = ?", hash).First(&existing).Error; err == nil { + return &existing, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + mimeType, err := detectMimeType(tmpName) + if err != nil { + return nil, err + } + extension := extensionFor(mimeType) + + relPath := path.Join(hash[:2], hash+extension) + finalPath := filepath.Join(root, filepath.FromSlash(relPath)) + if err := os.MkdirAll(filepath.Dir(finalPath), 0o755); err != nil { + return nil, fmt.Errorf("创建存储子目录失败: %w", err) + } + if err := tmp.Close(); err != nil { + return nil, fmt.Errorf("关闭临时文件失败: %w", err) + } + if err := os.Rename(tmpName, finalPath); err != nil { + return nil, fmt.Errorf("保存文件失败: %w", err) + } + + record := model.File{ + Name: displayName(filename), + Path: relPath, + Extension: extension, + MimeType: mimeType, + Size: size, + Hash: hash, + RefCount: 0, + UploaderID: operator.ID, + Storage: storageLocal, + Status: model.FileStatusEnabled, + } + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&record).Error; err != nil { + return err + } + return writeOperation(tx, model.FileOperationCreate, record, operator, "", "", record.Path, record.Name) + }) + if err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + if lookupErr := db.WithContext(ctx).Where("hash = ?", hash).First(&existing).Error; lookupErr == nil { + return &existing, nil + } + } + return nil, err + } + return &record, nil +} + +// DeleteFile 删除文件:仍被引用时返回 ErrFileInUse;物理删除后保留记录(status=0)并写日志。 +func DeleteFile(ctx context.Context, db *gorm.DB, cfg *config.Config, f model.File, operator Operator) error { + if f.RefCount > 0 { + return ErrFileInUse + } + if err := removeLocal(cfg.Storage.Dir, f.Path); err != nil { + return err + } + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.File{}).Where("id = ?", f.ID). + Update("status", model.FileStatusDisabled).Error; err != nil { + return err + } + return writeOperation(tx, model.FileOperationDelete, f, operator, f.Path, f.Name, "", "") + }) +} + +// Acquire 增加文件引用计数并刷新最后引用时间。 +func Acquire(ctx context.Context, tx *gorm.DB, id uint) error { + now := time.Now() + result := tx.WithContext(ctx).Model(&model.File{}). + Where("id = ? AND status = ?", id, model.FileStatusEnabled). + Updates(map[string]any{"ref_count": gorm.Expr("ref_count + 1"), "last_referenced_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrFileNotFound + } + return nil +} + +// Release 减少文件引用计数,最低减到 0;不会删除物理文件。 +func Release(ctx context.Context, tx *gorm.DB, id uint) error { + now := time.Now() + return tx.WithContext(ctx).Model(&model.File{}). + Where("id = ? AND ref_count > 0", id). + Updates(map[string]any{"ref_count": gorm.Expr("ref_count - 1"), "last_referenced_at": now}).Error +} + +// Open 打开文件记录对应的本地文件。 +func Open(root string, f model.File) (*os.File, error) { + full, err := localPath(root, f.Path) + if err != nil { + return nil, err + } + handle, err := os.Open(full) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrFileNotFound + } + return nil, err + } + return handle, nil +} + +// URL 返回文件的公开访问地址。 +func URL(prefix string, id uint) string { + return strings.TrimSuffix(prefix, "/") + "/files/" + strconv.FormatUint(uint64(id), 10) +} + +// ParseLocalURL 从本站文件地址中解析文件 ID,非本站地址返回 false。 +func ParseLocalURL(prefix, value string) (uint, bool) { + base := strings.TrimSuffix(prefix, "/") + "/files/" + if !strings.HasPrefix(value, base) { + return 0, false + } + id, err := strconv.ParseUint(strings.TrimPrefix(value, base), 10, 64) + if err != nil || id == 0 { + return 0, false + } + return uint(id), true +} + +// CanInline 是否可内联展示(避免同源 HTML/SVG 造成存储型 XSS)。 +func CanInline(mimeType string) bool { + switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { + case "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/avif", + "video/mp4", "video/webm", "audio/mpeg", "audio/ogg", "audio/wav", "application/pdf", "text/plain": + return true + default: + return false + } +} + +// detectMimeType 读取文件头部探测 MIME;探测失败时回退扩展名与通用类型。 +func detectMimeType(name string) (string, error) { + handle, err := os.Open(name) + if err != nil { + return "", fmt.Errorf("读取上传内容失败: %w", err) + } + defer handle.Close() + + head := make([]byte, 512) + n, err := handle.Read(head) + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("读取上传内容失败: %w", err) + } + if detected := http.DetectContentType(head[:n]); detected != "" && detected != "application/octet-stream" { + return detected, nil + } + if byExt := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))); byExt != "" { + return byExt, nil + } + return "application/octet-stream", nil +} + +// extensionFor 返回 MIME 对应的存储扩展名。 +func extensionFor(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "image/bmp": + return ".bmp" + case "image/avif": + return ".avif" + case "video/mp4": + return ".mp4" + case "video/webm": + return ".webm" + case "audio/mpeg": + return ".mp3" + case "audio/ogg": + return ".ogg" + case "audio/wav": + return ".wav" + case "application/pdf": + return ".pdf" + default: + return "" + } +} + +// displayName 清洗上传文件名,仅用于展示与下载名。 +func displayName(name string) string { + name = strings.TrimSpace(path.Base(strings.ReplaceAll(name, "\\", "/"))) + if name == "" || name == "." || name == ".." { + return "file" + } + if len(name) > 255 { + name = name[len(name)-255:] + } + return name +} + +// localPath 拼接并校验本地存储路径,防止目录穿越。 +func localPath(root, rel string) (string, error) { + if rel == "" { + return "", ErrFileNotFound + } + cleanRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + full := filepath.Join(cleanRoot, filepath.FromSlash(rel)) + if full != cleanRoot && !strings.HasPrefix(full, cleanRoot+string(os.PathSeparator)) { + return "", ErrFileNotFound + } + return full, nil +} + +func removeLocal(root, rel string) error { + full, err := localPath(root, rel) + if err != nil { + return err + } + if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("删除文件失败: %w", err) + } + return nil +} + +// writeOperation 追加文件操作日志。 +func writeOperation(tx *gorm.DB, operation string, f model.File, operator Operator, pathBefore, nameBefore, pathAfter, nameAfter string) error { + record := model.FileOperation{ + FileID: f.ID, + FileName: f.Name, + FileHash: f.Hash, + Operation: operation, + OperatorID: operator.ID, + Operator: operator.Name, + PathBefore: pathBefore, + NameBefore: nameBefore, + PathAfter: pathAfter, + NameAfter: nameAfter, + IP: operator.IP, + } + return tx.Create(&record).Error +} diff --git a/internal/file/file_test.go b/internal/file/file_test.go new file mode 100644 index 0000000..4af4a08 --- /dev/null +++ b/internal/file/file_test.go @@ -0,0 +1,218 @@ +package file_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "rill/internal/model" + "rill/internal/testutil" +) + +func registerUser(t *testing.T, env *testutil.Env, username string) model.User { + t.Helper() + w := testutil.Call(t, env.Router(""), http.MethodPost, "/api/auth/register", map[string]string{ + "username": username, "email": username + "@example.com", "password": "secret123", + }) + if w.Code != http.StatusCreated { + t.Fatalf("注册 %s 失败: %d, body=%s", username, w.Code, w.Body.String()) + } + return testutil.DecodeUser(t, w) +} + +func decodeFile(t *testing.T, body []byte) model.File { + t.Helper() + var record model.File + if err := json.Unmarshal(body, &record); err != nil { + t.Fatalf("解析文件响应失败: %v, body=%s", err, body) + } + return record +} + +func uploadFile(t *testing.T, env *testutil.Env, token, filename string, content []byte) model.File { + t.Helper() + w := testutil.CallMultipart(t, env.Router(token), http.MethodPost, "/api/files", "file", filename, content) + if w.Code != http.StatusCreated { + t.Fatalf("上传 %s 状态码 = %d, 期望 %d, body=%s", filename, w.Code, http.StatusCreated, w.Body.String()) + } + return decodeFile(t, w.Body.Bytes()) +} + +func TestFileUploadDedupAndView(t *testing.T) { + env := testutil.Setup(t) + user := registerUser(t, env, "fileuser") + token := env.Sign(user.ID) + content := testutil.PNG(t, 4, 4) + + created := uploadFile(t, env, token, "photo.png", content) + if created.ID == 0 || created.MimeType != "image/png" || created.Extension != ".png" { + t.Fatalf("上传结果异常: %+v", created) + } + if created.Size != int64(len(content)) || created.RefCount != 0 || created.Status != model.FileStatusEnabled { + t.Errorf("上传元数据异常: %+v", created) + } + if created.UploaderID == nil || *created.UploaderID != user.ID { + t.Errorf("上传者异常: %+v", created.UploaderID) + } + + full := filepath.Join(env.Cfg.Storage.Dir, filepath.FromSlash(created.Path)) + if _, err := os.Stat(full); err != nil { + t.Fatalf("物理文件不存在: %v", err) + } + + // 相同内容秒传:复用同一条记录,不重复落盘。 + again := uploadFile(t, env, token, "copy.png", content) + if again.ID != created.ID { + t.Errorf("秒传未复用记录: first=%d again=%d", created.ID, again.ID) + } + var count int64 + if err := env.DB.Model(&model.File{}).Where("hash = ?", created.Hash).Count(&count).Error; err != nil { + t.Fatalf("统计文件失败: %v", err) + } + if count != 1 { + t.Errorf("相同内容文件记录数 = %d, 期望 1", count) + } + + // 不同内容生成新记录。 + other := uploadFile(t, env, token, "other.png", testutil.PNG(t, 6, 6)) + if other.ID == created.ID { + t.Error("不同内容不应复用记录") + } + + // 查看接口公开可访问。 + viewPath := fmt.Sprintf("/api/files/%d", created.ID) + w := testutil.Call(t, env.Router(""), http.MethodGet, viewPath, nil) + if w.Code != http.StatusOK { + t.Fatalf("公开查看状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + if !bytes.Equal(w.Body.Bytes(), content) { + t.Error("查看内容与上传内容不一致") + } + if got := w.Header().Get("Content-Type"); got != "image/png" { + t.Errorf("Content-Type = %q, 期望 image/png", got) + } + if got := w.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") { + t.Errorf("图片应内联展示, Content-Disposition = %q", got) + } + if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("缺少 nosniff 头: %q", got) + } + + // 上传写 create 日志。 + var created2 []model.FileOperation + if err := env.DB.Where("file_id = ? AND operation = ?", created.ID, model.FileOperationCreate). + Find(&created2).Error; err != nil { + t.Fatalf("查询操作日志失败: %v", err) + } + if len(created2) != 1 { + t.Fatalf("create 日志数 = %d, 期望 1", len(created2)) + } + if created2[0].Operator != user.Username || created2[0].PathAfter != created.Path { + t.Errorf("create 日志内容异常: %+v", created2[0]) + } +} + +func TestFileUploadValidation(t *testing.T) { + env := testutil.Setup(t) + user := registerUser(t, env, "fileuser") + token := env.Sign(user.ID) + + if w := testutil.CallMultipart(t, env.Router(""), http.MethodPost, "/api/files", "file", "a.png", testutil.PNG(t, 2, 2)); w.Code != http.StatusUnauthorized { + t.Errorf("匿名上传状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized) + } + + if w := testutil.CallMultipart(t, env.Router(token), http.MethodPost, "/api/files", "file", "empty.png", nil); w.Code != http.StatusBadRequest { + t.Errorf("空文件状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String()) + } + + tooLarge := bytes.Repeat([]byte("a"), int(env.Cfg.MaxUploadBytes())+1) + if w := testutil.CallMultipart(t, env.Router(token), http.MethodPost, "/api/files", "file", "big.bin", tooLarge); w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("超限文件状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusRequestEntityTooLarge, w.Body.String()) + } + + if w := testutil.Call(t, env.Router(token), http.MethodGet, "/api/files/abc", nil); w.Code != http.StatusBadRequest { + t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest) + } + if w := testutil.Call(t, env.Router(token), http.MethodGet, "/api/files/9999", nil); w.Code != http.StatusNotFound { + t.Errorf("不存在文件状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } +} + +func TestFileDeletePermissions(t *testing.T) { + env := testutil.Setup(t) + owner := registerUser(t, env, "owner") + other := registerUser(t, env, "other") + record := uploadFile(t, env, env.Sign(owner.ID), "photo.png", testutil.PNG(t, 5, 5)) + path := fmt.Sprintf("/api/files/%d", record.ID) + + if w := testutil.Call(t, env.Router(env.Sign(other.ID)), http.MethodDelete, path, nil); w.Code != http.StatusForbidden { + t.Errorf("他人删除状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusForbidden, w.Body.String()) + } + + if err := env.DB.Model(&model.File{}).Where("id = ?", record.ID).Update("ref_count", 1).Error; err != nil { + t.Fatalf("设置引用计数失败: %v", err) + } + if w := testutil.Call(t, env.Router(env.Sign(owner.ID)), http.MethodDelete, path, nil); w.Code != http.StatusConflict { + t.Errorf("引用中删除状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusConflict, w.Body.String()) + } + if err := env.DB.Model(&model.File{}).Where("id = ?", record.ID).Update("ref_count", 0).Error; err != nil { + t.Fatalf("重置引用计数失败: %v", err) + } + + // 管理员可删除他人文件。 + w := testutil.Call(t, env.AdminRouter(), http.MethodDelete, path, nil) + if w.Code != http.StatusNoContent { + t.Fatalf("管理员删除状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusNoContent, w.Body.String()) + } + + if w := testutil.Call(t, env.Router(""), http.MethodGet, path, nil); w.Code != http.StatusNotFound { + t.Errorf("删除后查看状态码 = %d, 期望 %d", w.Code, http.StatusNotFound) + } + var stored model.File + if err := env.DB.First(&stored, record.ID).Error; err != nil { + t.Fatalf("查询文件记录失败: %v", err) + } + if stored.Status != model.FileStatusDisabled { + t.Errorf("删除后 status = %d, 期望 %d", stored.Status, model.FileStatusDisabled) + } + if _, err := os.Stat(filepath.Join(env.Cfg.Storage.Dir, filepath.FromSlash(record.Path))); !os.IsNotExist(err) { + t.Errorf("物理文件未删除: %v", err) + } + var logs []model.FileOperation + if err := env.DB.Where("file_id = ? AND operation = ?", record.ID, model.FileOperationDelete). + Find(&logs).Error; err != nil { + t.Fatalf("查询操作日志失败: %v", err) + } + if len(logs) != 1 || logs[0].PathBefore != record.Path || logs[0].NameBefore != record.Name { + t.Errorf("delete 日志异常: %+v", logs) + } +} + +func TestFilePathTraversalGuard(t *testing.T) { + env := testutil.Setup(t) + secret := filepath.Join(env.Cfg.Storage.Dir, "..", "secret.txt") + if err := os.MkdirAll(filepath.Dir(secret), 0o755); err != nil { + t.Fatalf("创建目录失败: %v", err) + } + if err := os.WriteFile(secret, []byte("top-secret"), 0o644); err != nil { + t.Fatalf("写入测试文件失败: %v", err) + } + + record := model.File{ + Name: "secret.txt", Path: "../secret.txt", MimeType: "text/plain", + Size: 10, Hash: strings.Repeat("a", 64), Status: model.FileStatusEnabled, + } + if err := env.DB.Create(&record).Error; err != nil { + t.Fatalf("创建文件记录失败: %v", err) + } + + w := testutil.Call(t, env.Router(""), http.MethodGet, fmt.Sprintf("/api/files/%d", record.ID), nil) + if w.Code != http.StatusNotFound { + t.Errorf("目录穿越状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusNotFound, w.Body.String()) + } +} diff --git a/internal/file/handler.go b/internal/file/handler.go new file mode 100644 index 0000000..92ec9b7 --- /dev/null +++ b/internal/file/handler.go @@ -0,0 +1,202 @@ +package file + +import ( + "errors" + "fmt" + "mime/multipart" + "net/http" + "net/url" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "rill/internal/auth" + "rill/internal/config" + "rill/internal/httpx" + "rill/internal/model" + "rill/internal/utils" +) + +// multipartOverhead 预留 multipart 边界与头部体积。 +const multipartOverhead = 1 << 20 + +// ReadUpload 按配置限制请求体大小并读取 multipart 字段 file,失败时已写入响应。 +func ReadUpload(c *gin.Context, cfg *config.Config) (*multipart.FileHeader, bool) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cfg.MaxUploadBytes()+multipartOverhead) + header, err := c.FormFile("file") + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, httpx.ErrorResponse{Error: "file too large"}) + return nil, false + } + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: file is required"}) + return nil, false + } + return header, true +} + +// RespondSaveError 将存储错误映射为 HTTP 响应。 +func RespondSaveError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrTooLarge): + c.JSON(http.StatusRequestEntityTooLarge, httpx.ErrorResponse{Error: err.Error()}) + case errors.Is(err, ErrEmpty): + c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: err.Error()}) + default: + httpx.RespondDBError(c, err) + } +} + +// OperatorOf 构造操作人快照。 +func OperatorOf(c *gin.Context, user model.User) Operator { + name := user.Nickname + if name == "" { + name = user.Username + } + return Operator{ID: &user.ID, Name: name, IP: utils.ClientIP(c)} +} + +// @Summary Upload a file +// @Description Upload a file (multipart field file). Content is deduplicated by sha256; the returned file has ref_count 0 until a business reference is acquired. Size limit from storage.max_size_mb. +// @Tags user +// @Accept mpfd +// @Produce json +// @Param file formData file true "File content" +// @Success 201 {object} model.File +// @Failure 400 {object} httpx.ErrorResponse "invalid request or empty file" +// @Failure 413 {object} httpx.ErrorResponse "file too large" +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" +// @Failure 403 {object} httpx.ErrorResponse "account disabled" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /files [post] +func Upload(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + current, ok := auth.CurrentUser(c) + if !ok { + httpx.RespondUnauthorized(c) + return + } + header, ok := ReadUpload(c, cfg) + if !ok { + return + } + src, err := header.Open() + if err != nil { + httpx.RespondServerError(c, err, "打开上传文件失败") + return + } + defer src.Close() + + saved, err := Save(c.Request.Context(), db, cfg, OperatorOf(c, current), header.Filename, src) + if err != nil { + RespondSaveError(c, err) + return + } + c.JSON(http.StatusCreated, saved) + } +} + +// @Summary Delete a file +// @Description Delete a file physically and keep the record with status 0; uploader or admin only. Files still referenced (ref_count > 0) return 409. +// @Tags user +// @Produce json +// @Param id path int true "File ID" example(1) +// @Success 204 "Deleted" +// @Failure 400 {object} httpx.ErrorResponse "invalid id" +// @Failure 403 {object} httpx.ErrorResponse "permission denied or account disabled" +// @Failure 404 {object} httpx.ErrorResponse "record not found" +// @Failure 409 {object} httpx.ErrorResponse "file is in use" +// @Security BearerAuth +// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /files/{id} [delete] +func Delete(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + current, ok := auth.CurrentUser(c) + if !ok { + httpx.RespondUnauthorized(c) + return + } + id, ok := httpx.ParseID(c) + if !ok { + return + } + + ctx := c.Request.Context() + var record model.File + if err := db.WithContext(ctx).First(&record, id).Error; err != nil { + httpx.RespondGetError(c, err) + return + } + if record.UploaderID == nil || *record.UploaderID != current.ID { + if !current.IsAdmin() { + c.JSON(http.StatusForbidden, httpx.ErrorResponse{Error: "permission denied"}) + return + } + } + if err := DeleteFile(ctx, db, cfg, record, OperatorOf(c, current)); err != nil { + if errors.Is(err, ErrFileInUse) { + c.JSON(http.StatusConflict, httpx.ErrorResponse{Error: err.Error()}) + return + } + httpx.RespondDBError(c, err) + return + } + c.Status(http.StatusNoContent) + } +} + +// @Summary Get file content +// @Description Public file content. Images, videos, audio, PDF and plain text are served inline; other types are served as attachments. Disabled files return 404. +// @Tags public +// @Produce application/octet-stream +// @Param id path int true "File ID" example(1) +// @Success 200 {file} binary +// @Failure 400 {object} httpx.ErrorResponse "invalid id" +// @Failure 404 {object} httpx.ErrorResponse "record not found" +// @Failure 500 {object} httpx.ErrorResponse +// @Router /files/{id} [get] +func View(db *gorm.DB, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + id, ok := httpx.ParseID(c) + if !ok { + return + } + + var record model.File + if err := db.WithContext(c.Request.Context()).First(&record, id).Error; err != nil { + httpx.RespondGetError(c, err) + return + } + if record.Status != model.FileStatusEnabled { + c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: "record not found"}) + return + } + + handle, err := Open(cfg.Storage.Dir, record) + if err != nil { + if errors.Is(err, ErrFileNotFound) { + c.JSON(http.StatusNotFound, httpx.ErrorResponse{Error: "record not found"}) + return + } + httpx.RespondDBError(c, err) + return + } + defer handle.Close() + + contentType := record.MimeType + if contentType == "" { + contentType = "application/octet-stream" + } + disposition := "attachment" + if CanInline(contentType) { + disposition = "inline" + } + c.Header("Content-Disposition", fmt.Sprintf("%s; filename*=UTF-8''%s", disposition, url.PathEscape(record.Name))) + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Cache-Control", viewCacheControl) + c.DataFromReader(http.StatusOK, record.Size, contentType, handle, nil) + } +} diff --git a/internal/model/user.go b/internal/model/user.go index 42eede5..d76a431 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -29,3 +29,13 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } + +// IsAdmin 是否属于管理员组。 +func (u User) IsAdmin() bool { + for _, group := range u.Groups { + if group.ID == GroupIDAdmin { + return true + } + } + return false +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 2978b61..983e396 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -5,6 +5,10 @@ import ( "bytes" "context" "encoding/json" + "image" + "image/color" + "image/png" + "mime/multipart" "net/http" "net/http/httptest" "path/filepath" @@ -44,6 +48,11 @@ func Setup(t *testing.T) *Env { Secret: "test-secret", TokenTTL: "1h", }, + Storage: config.StorageConfig{ + Dir: filepath.Join(t.TempDir(), "uploads"), + MaxSizeMB: 1, + }, + API: config.APIConfig{Prefix: "/api"}, } db, err := database.Open(cfg) if err != nil { @@ -118,6 +127,30 @@ func Call(t *testing.T, r http.Handler, method, path string, body any) *httptest return w } +// CallMultipart 发送单文件 multipart/form-data 请求并返回响应记录器。 +func CallMultipart(t *testing.T, r http.Handler, method, path, field, filename string, content []byte) *httptest.ResponseRecorder { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile(field, filename) + if err != nil { + t.Fatalf("创建 multipart 字段失败: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("写入 multipart 内容失败: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("关闭 multipart 失败: %v", err) + } + + req := httptest.NewRequest(method, path, &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + // DecodeUser 解析用户响应。 func DecodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User { t.Helper() @@ -127,3 +160,19 @@ func DecodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User { } return user } + +// PNG 生成指定尺寸的测试 PNG 图片内容。 +func PNG(t *testing.T, width, height int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, color.RGBA{R: uint8(x * 7), G: uint8(y * 11), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("生成测试图片失败: %v", err) + } + return buf.Bytes() +}