Compare commits
4
Commits
80588a70dd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9015aad0 | ||
|
|
84df9996cb | ||
|
|
27432218c4 | ||
|
|
00a8c16ab7 |
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI 测试
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# 镜像发布不再走 CI:GoReleaser Pro(本地 goreleaser release --clean)
|
||||||
|
# 负责多平台归档 + Gitea Release + ACR 双架构镜像(见 .goreleaser.yaml)。
|
||||||
|
# 本工作流只做推送/PR 前置测试门禁。
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: go vet + go test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: golang:1.27.1-alpine
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: 安装工具并检出
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl bash >/dev/null
|
||||||
|
SCHEME="${GITHUB_SERVER_URL%%://*}"
|
||||||
|
SRV="${GITHUB_SERVER_URL#*://}"
|
||||||
|
echo "clone from ${SCHEME}://${SRV}"
|
||||||
|
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
|
||||||
|
"${SCHEME}://oauth2:${GITHUB_TOKEN}@${SRV}/${GITHUB_REPOSITORY}.git" .
|
||||||
|
|
||||||
|
- name: go vet + go test
|
||||||
|
working-directory: server
|
||||||
|
env:
|
||||||
|
GOCACHE: /tmp/.gocache
|
||||||
|
GOMODCACHE: /tmp/.gomodcache
|
||||||
|
CGO_ENABLED: "0"
|
||||||
|
run: |
|
||||||
|
go vet ./...
|
||||||
|
go test ./... -count=1
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
name: Release 镜像
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
tags: ["v*", "26.*", "27.*"]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: registry.cn-hangzhou.aliyuncs.com
|
|
||||||
IMAGE: registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: 测试(推送前置门禁)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container:
|
|
||||||
image: golang:1.27.1-alpine
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: 安装工具并检出
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
|
||||||
apk add --no-cache git curl bash >/dev/null
|
|
||||||
SCHEME="${GITHUB_SERVER_URL%%://*}"
|
|
||||||
SRV="${GITHUB_SERVER_URL#*://}"
|
|
||||||
echo "clone from ${SCHEME}://${SRV}"
|
|
||||||
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
|
|
||||||
"${SCHEME}://oauth2:${GITHUB_TOKEN}@${SRV}/${GITHUB_REPOSITORY}.git" .
|
|
||||||
|
|
||||||
- name: go vet + go test
|
|
||||||
working-directory: server
|
|
||||||
env:
|
|
||||||
GOCACHE: /tmp/.gocache
|
|
||||||
GOMODCACHE: /tmp/.gomodcache
|
|
||||||
CGO_ENABLED: "0"
|
|
||||||
run: |
|
|
||||||
go vet ./...
|
|
||||||
go test ./... -count=1
|
|
||||||
|
|
||||||
build-push:
|
|
||||||
name: 多架构构建并推送 ACR
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# dind 自带独立 daemon:不依赖 runner 宿主机 docker.sock 转发配置
|
|
||||||
container:
|
|
||||||
image: docker:27-dind
|
|
||||||
options: --privileged
|
|
||||||
env:
|
|
||||||
DOCKER_HOST: unix:///var/run/docker.sock
|
|
||||||
DOCKER_TLS_CERTDIR: ""
|
|
||||||
timeout-minutes: 120
|
|
||||||
steps:
|
|
||||||
- name: 启动 dind daemon 并检出
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
|
||||||
apk add --no-cache git bash >/dev/null
|
|
||||||
# dind daemon 配置国内 registry mirror(docker.io 直连不可达)
|
|
||||||
mkdir -p /etc/docker
|
|
||||||
printf '{"registry-mirrors":["https://docker.m.daocloud.io","https://docker.1ms.run"]}' \
|
|
||||||
> /etc/docker/daemon.json
|
|
||||||
dockerd --host=unix:///var/run/docker.sock >/tmp/dockerd.log 2>&1 &
|
|
||||||
for i in $(seq 1 30); do docker info >/dev/null 2>&1 && break; sleep 1; done
|
|
||||||
docker info --format 'dind 就绪: {{.ServerVersion}}'
|
|
||||||
SCHEME="${GITHUB_SERVER_URL%%://*}"
|
|
||||||
SRV="${GITHUB_SERVER_URL#*://}"
|
|
||||||
git clone --depth=1 --branch "$GITHUB_REF_NAME" \
|
|
||||||
"${SCHEME}://oauth2:${GITHUB_TOKEN}@${SRV}/${GITHUB_REPOSITORY}.git" .
|
|
||||||
|
|
||||||
- name: 安装 QEMU binfmt(跨架构)
|
|
||||||
run: |
|
|
||||||
docker run --rm --privileged tonistiigi/binfmt:latest --install all 2>/dev/null \
|
|
||||||
|| docker run --rm --privileged docker.m.daocloud.io/tonistiigi/binfmt:latest --install all
|
|
||||||
|
|
||||||
- name: 计算 tag(以 APP_VERSION 为唯一版本源)
|
|
||||||
id: meta
|
|
||||||
env:
|
|
||||||
REF: ${{ gitea.ref }}
|
|
||||||
run: |
|
|
||||||
VER=$(sed -n 's/.*APP_VERSION = "\(.*\)".*/\1/p' server/cmd/server/main.go | head -1)
|
|
||||||
[ -n "$VER" ] || { echo "无法从 main.go 解析 APP_VERSION" >&2; exit 1; }
|
|
||||||
echo "APP_VERSION=$VER"
|
|
||||||
if [[ "$REF" == refs/tags/* ]]; then
|
|
||||||
# tag 触发:要求 tag 名与 APP_VERSION 一致,防错版发布
|
|
||||||
TAG_VER="${REF#refs/tags/}"
|
|
||||||
TAG_VER="${TAG_VER#v}"
|
|
||||||
[ "$TAG_VER" = "$VER" ] || { echo "tag($TAG_VER) != APP_VERSION($VER),拒绝发布" >&2; exit 1; }
|
|
||||||
fi
|
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tags=${IMAGE}:${VER} ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: 登录阿里云 ACR
|
|
||||||
env:
|
|
||||||
ACR_USER: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASS: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
echo "ACR 用户: $ACR_USER (密码 ${#ACR_PASS} 位)"
|
|
||||||
printf '%s' "$ACR_PASS" | docker login "$REGISTRY" -u "$ACR_USER" --password-stdin
|
|
||||||
|
|
||||||
- name: buildx 多架构构建并推送
|
|
||||||
env:
|
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
|
||||||
run: |
|
|
||||||
# docker-container 驱动才支持多平台。基础镜像/APK/NPM 全部走国内源:
|
|
||||||
# runner 侧网络对 docker.io 存在 DNS 污染(证书误配 facebook 域),不可直连
|
|
||||||
docker buildx create --name multiarch --driver docker-container >/dev/null 2>&1 || true
|
|
||||||
docker buildx use multiarch
|
|
||||||
ARGS=""
|
|
||||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
|
||||||
# provenance/sbom 必须关:阿里云 ACR 不识别 OCI empty manifest(attestation)
|
|
||||||
# buildx 会把 docker login 的 ACR 凭据转发给 buildkitd(私有基础镜像可拉)
|
|
||||||
docker buildx build \
|
|
||||||
--builder multiarch \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--provenance=false --sbom=false \
|
|
||||||
--build-arg NODE_IMAGE=registry.cn-hangzhou.aliyuncs.com/skymirror/node:20-alpine \
|
|
||||||
--build-arg GO_IMAGE=registry.cn-hangzhou.aliyuncs.com/skymirror/golang:1.27.1-alpine \
|
|
||||||
--build-arg RUNTIME_IMAGE=registry.cn-hangzhou.aliyuncs.com/skymirror/alpine:3.20 \
|
|
||||||
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
|
|
||||||
--build-arg APK_MIRROR=https://mirrors.aliyun.com \
|
|
||||||
--push \
|
|
||||||
-f deploy/Dockerfile \
|
|
||||||
$ARGS \
|
|
||||||
.
|
|
||||||
|
|
||||||
- name: 校验远程 manifest(双架构)
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.meta.outputs.version }}
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}" | grep -E "linux/amd64|linux/arm64"
|
|
||||||
echo "推送完成: ${IMAGE}:${VERSION} + ${IMAGE}:latest"
|
|
||||||
+17
-8
@@ -1,13 +1,22 @@
|
|||||||
# 文件快传 GoReleaser 配置(OSS v2.14.1+)
|
# 文件快传 GoReleaser 配置(Pro 2.18.1,二进制在 ~/Code/Releaser/goreleaser)
|
||||||
#
|
#
|
||||||
# 用法:
|
# 用法:
|
||||||
# goreleaser release --snapshot --clean # 本地试跑,产出 ./dist
|
# goreleaser release --snapshot --clean --skip=publish # 本地试跑,产出 ./dist
|
||||||
# goreleaser release --clean # 正式发布(需 git tag + 远端可写)
|
# goreleaser release --clean # 正式发布(需 semver tag)
|
||||||
#
|
#
|
||||||
# 注入凭据的环境变量(不入库):
|
# tag 必须是语义化版本(goreleaser 强制),如 v26.9.0(→ 版本 26.9.0),
|
||||||
# GORELEASER_GITEA_TOKEN Gitea Personal Access Token(uploads + release)
|
# 并与 server/cmd/server/main.go 的 APP_VERSION 保持一致。
|
||||||
# GORELEASER_ACR_USER ACR 用户名
|
#
|
||||||
# GORELEASER_ACR_PASS ACR 密码
|
# 发布所需环境变量(不入库):
|
||||||
|
# GITEA_TOKEN Gitea Personal Access Token(release + 资产上传)
|
||||||
|
# BUILDX_CONFIG=/tmp/buildx-config 可选:buildx 状态目录重定向(受限环境)
|
||||||
|
# DOCKER_CONFIG=/tmp/docker-config 可选:docker 配置重定向(manifest 需写 ~/.docker 时)
|
||||||
|
# ACR 登录:docker login registry.cn-hangzhou.aliyuncs.com(镜像推送用本机 docker 凭证)
|
||||||
|
#
|
||||||
|
# 产物:
|
||||||
|
# - 归档:linux/darwin/windows × amd64/arm64(tar.gz + zip)→ Gitea Release
|
||||||
|
# - 镜像:registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{版本}-amd64/-arm64
|
||||||
|
# + 多架构 manifest {版本} 与 latest → 阿里云 ACR
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
@@ -156,7 +165,7 @@ docker_manifests:
|
|||||||
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
|
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-amd64'
|
||||||
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
|
- 'registry.cn-hangzhou.aliyuncs.com/skymirror/fileshare:{{ .Version }}-arm64'
|
||||||
|
|
||||||
# Gitea 服务器(v2.14 OSS:gitea_urls 在顶层为对象;token 走 GORELEASER_TOKEN env)
|
# Gitea 服务器(token 走 GITEA_TOKEN env)
|
||||||
gitea_urls:
|
gitea_urls:
|
||||||
api: https://git.skymirror.top/api/v1
|
api: https://git.skymirror.top/api/v1
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
数据库**默认 SQLite 零依赖**(modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/fileshare.db`),
|
数据库**默认 SQLite 零依赖**(modernc.org/sqlite 纯 Go 驱动,数据文件 `./data/fileshare.db`),
|
||||||
可选切换 Postgres(`FCB_DB_DRIVER=postgres` + DSN);Redis 为**可选**增强(未配置时自动降级为进程内存缓存)。
|
可选切换 Postgres(`FCB_DB_DRIVER=postgres` + DSN);Redis 为**可选**增强(未配置时自动降级为进程内存缓存)。
|
||||||
存储引擎支持 **本地 / S3 / WebDAV**(运行时热切换,健康检查通过才生效;WebDAV 重点优化:流式、Range、重试、连接复用)。
|
存储引擎支持 **本地 / S3 / WebDAV**(运行时热切换,健康检查通过才生效;WebDAV 重点优化:流式、Range、重试、连接复用)。
|
||||||
v3.2 起支持**上下行带宽限速**(`upload_rate` / `download_rate` 字节/秒,0=不限速,管理端改后立即生效)。
|
26.9 起支持**上下行带宽限速**(`upload_rate` / `download_rate` 字节/秒,0=不限速,管理端改后立即生效)。
|
||||||
|
|
||||||
**v3.2 新增**:上下行带宽限速(见 [专题](docs/api/13-bandwidth.md))· 站点对外域名、自定义提取码(v3.1)·
|
**26.9 新增**:上下行带宽限速(见 [专题](docs/api/13-bandwidth.md))· 站点对外域名、自定义提取码(26.9)·
|
||||||
本地/S3/WebDAV 存储引擎 v3 运行时热切换。
|
本地/S3/WebDAV 存储引擎 26.9 运行时热切换。
|
||||||
|
|
||||||
| 目录 | 说明 |
|
| 目录 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -21,7 +21,7 @@ v3.2 起支持**上下行带宽限速**(`upload_rate` / `download_rate` 字节
|
|||||||
## 默认 Logo 与自定义
|
## 默认 Logo 与自定义
|
||||||
|
|
||||||
- 页面导航 Logo:本地资源 `web/src/assets/brand/logo.svg`
|
- 页面导航 Logo:本地资源 `web/src/assets/brand/logo.svg`
|
||||||
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`(v2 起不再使用远程 URL 默认值)
|
- favicon / 备用 Logo:本地资源 `web/src/assets/brand/favicon.png`(26.9 起不再使用远程 URL 默认值)
|
||||||
|
|
||||||
管理端自定义 Logo 三步:
|
管理端自定义 Logo 三步:
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
|
|||||||
|---|---|
|
|---|---|
|
||||||
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo/
|
| API 操作文档(概述/认证/分享/分片/预签名/管理后台/审计/存储/配置/错误码/Logo/
|
||||||
**带宽限速**) | 站内 `/docs`,源文件 [docs/api/](docs/api/) |
|
**带宽限速**) | 站内 `/docs`,源文件 [docs/api/](docs/api/) |
|
||||||
| ↳ **v3.2 带宽限速**(`upload_rate` / `download_rate`,管理端改后立即生效) | [docs/api/13-bandwidth.md](docs/api/13-bandwidth.md) |
|
| ↳ **26.9 带宽限速**(`upload_rate` / `download_rate`,管理端改后立即生效) | [docs/api/13-bandwidth.md](docs/api/13-bandwidth.md) |
|
||||||
| OpenAPI 3.0 规范 + Swagger UI | 站内 `/openapi`,源文件 `docs/openapi.yaml` |
|
| OpenAPI 3.0 规范 + Swagger UI | 站内 `/openapi`,源文件 `docs/openapi.yaml` |
|
||||||
| 后端设计契约 | [server/README.md](server/README.md) |
|
| 后端设计契约 | [server/README.md](server/README.md) |
|
||||||
| 前端说明与渲染约定 | [web/README.md](web/README.md) |
|
| 前端说明与渲染约定 | [web/README.md](web/README.md) |
|
||||||
@@ -94,7 +94,7 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
|
|||||||
|
|
||||||
| 变量 | 必需 | 默认 | 说明 |
|
| 变量 | 必需 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(v2 需求 ⑧) |
|
| `FCB_DB_DRIVER` | ❌ | `sqlite` | 数据库驱动:`sqlite` \| `postgres`(26.9 需求 ⑧) |
|
||||||
| `FCB_DB_DSN` | 视驱动 | - | postgres:连接串(**必需**);sqlite:文件路径(可空,默认 `./data/fileshare.db`) |
|
| `FCB_DB_DSN` | 视驱动 | - | postgres:连接串(**必需**);sqlite:文件路径(可空,默认 `./data/fileshare.db`) |
|
||||||
| `FCB_REDIS_ADDR` | ❌ | 空 | 为空时缓存降级为内存实现;支持 `redis://[:password@]host:port[/db]` / `rediss://` URL 形式 |
|
| `FCB_REDIS_ADDR` | ❌ | 空 | 为空时缓存降级为内存实现;支持 `redis://[:password@]host:port[/db]` / `rediss://` URL 形式 |
|
||||||
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15(URL 显式 `/N` 时以 URL 为准) |
|
| `FCB_REDIS_DB` | ❌ | `0` | Redis 逻辑库号 0-15(URL 显式 `/N` 时以 URL 为准) |
|
||||||
@@ -115,16 +115,16 @@ npm run build # 产出 web/dist/,构建时按 deploy/Dockerfil
|
|||||||
|
|
||||||
### 运行时配置(settings KV,管理端可改)
|
### 运行时配置(settings KV,管理端可改)
|
||||||
|
|
||||||
站点信息(`site_name`、`logo_url`、`favicon_url`、`page_explain` 等)、v2 展示与通知
|
站点信息(`site_name`、`logo_url`、`favicon_url`、`page_explain` 等)、26.9 展示与通知
|
||||||
(`background_url`、`footer_text`、`footer_beian`、`notify_enabled`、`notify_title/content`)、
|
(`background_url`、`footer_text`、`footer_beian`、`notify_enabled`、`notify_title/content`)、
|
||||||
上传策略(`openUpload`、`enableChunk`、`uploadSize`、`allowed_file_types`、`expireStyle`、
|
上传策略(`openUpload`、`enableChunk`、`uploadSize`、`allowed_file_types`、`expireStyle`、
|
||||||
`code_generate_type`、`max_save_seconds`、`storageLimit`,及 v2 上限键 `max_save_count`、
|
`code_generate_type`、`max_save_seconds`、`storageLimit`,及 26.9 上限键 `max_save_count`、
|
||||||
`max_file_size`)、限流(`uploadCount/uploadMinute`、`errorCount/errorMinute`、
|
`max_file_size`)、限流(`uploadCount/uploadMinute`、`errorCount/errorMinute`、
|
||||||
`loginCount/loginMinute`)、安全(`adminSessionExpire`;`admin_token`/`jwt_secret` 由系统管理)。
|
`loginCount/loginMinute`)、安全(`adminSessionExpire`;`admin_token`/`jwt_secret` 由系统管理)。
|
||||||
v3.2 带宽(`upload_rate` / `download_rate`,字节/秒,0=不限速)。
|
26.9 带宽(`upload_rate` / `download_rate`,字节/秒,0=不限速)。
|
||||||
|
|
||||||
完整键表与默认值见《[环境变量与配置项](docs/api/10-config.md)》;
|
完整键表与默认值见《[环境变量与配置项](docs/api/10-config.md)》;
|
||||||
v3.2 带宽限速专题《[带宽限速](docs/api/13-bandwidth.md)》;
|
26.9 带宽限速专题《[带宽限速](docs/api/13-bandwidth.md)》;
|
||||||
修改接口见《[管理后台 API](docs/api/07-admin.md)》(`PATCH /admin/config/update`,改密自动轮换 jwt_secret)。
|
修改接口见《[管理后台 API](docs/api/07-admin.md)》(`PATCH /admin/config/update`,改密自动轮换 jwt_secret)。
|
||||||
|
|
||||||
## 审计日志
|
## 审计日志
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
# 文件快传 部署编排(deploy/)
|
# 文件快传 部署编排(deploy/)
|
||||||
|
|
||||||
Go 1.27.1(Gin + GORM)+ Vue 3 重写版(v3.1)的容器化部署。数据库**默认 SQLite,零外部依赖**
|
Go 1.27.1(Gin + GORM)+ Vue 3 重写版(26.9)的容器化部署。数据库**默认 SQLite,零外部依赖**
|
||||||
(modernc.org/sqlite 纯 Go 驱动,无需 Postgres),可选切换 Postgres(`--profile postgres`);
|
(modernc.org/sqlite 纯 Go 驱动,无需 Postgres),可选切换 Postgres(`--profile postgres`);
|
||||||
Redis 为可选增强(`--profile redis`),未配置 `FCB_REDIS_ADDR` 时服务端自动降级为进程内存缓存。
|
Redis 为可选增强(`--profile redis`),未配置 `FCB_REDIS_ADDR` 时服务端自动降级为进程内存缓存。
|
||||||
|
|
||||||
> v3.1 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
|
> 26.9 功能提示:管理后台可设「站点对外域名」(内网部署生成公网分享链接)、
|
||||||
> 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。
|
> 分享时支持自定义提取码(4-8 位字母数字);这些均为运行时配置,无需改部署。
|
||||||
> v3.2 新增:管理后台可设「上行/下行带宽」限速(字节/秒,0=不限速),立即生效。
|
> 26.9 新增:管理后台可设「上行/下行带宽」限速(字节/秒,0=不限速),立即生效。
|
||||||
> S3 预签名直传(客户端→S3)服务端无法介入限速,其余下载/上传路径均覆盖。详见《[带宽限速](../docs/api/13-bandwidth.md)》。
|
> S3 预签名直传(客户端→S3)服务端无法介入限速,其余下载/上传路径均覆盖。详见《[带宽限速](../docs/api/13-bandwidth.md)》。
|
||||||
|
|
||||||
## compose 服务与 profile 一览
|
## compose 服务与 profile 一览
|
||||||
@@ -73,7 +73,7 @@ docker compose --profile minio up -d --build # .env: FCB_STORAGE_ENGINE=s3
|
|||||||
docker compose --profile webdav up -d --build # .env: FCB_STORAGE_ENGINE=webdav
|
docker compose --profile webdav up -d --build # .env: FCB_STORAGE_ENGINE=webdav
|
||||||
```
|
```
|
||||||
|
|
||||||
> **v3 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
|
> **26.9 起支持运行时热切换**:也可不改 `.env`,直接在管理后台「系统设置 → 存储引擎」
|
||||||
> 三选并保存引擎参数后点切换(健康检查通过才生效,失败保持原引擎),或调用
|
> 三选并保存引擎参数后点切换(健康检查通过才生效,失败保持原引擎),或调用
|
||||||
> `POST /admin/storage/switch`——均无需重启容器。`.env` 的 `FCB_STORAGE_ENGINE`
|
> `POST /admin/storage/switch`——均无需重启容器。`.env` 的 `FCB_STORAGE_ENGINE`
|
||||||
> 仅作为首次启动(KV 为空时)的默认引擎。
|
> 仅作为首次启动(KV 为空时)的默认引擎。
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
- **v3.2**:上下行带宽限速(`upload_rate` / `download_rate`,详见《[带宽限速](13-bandwidth.md)》)
|
- **26.9**:上下行带宽限速(`upload_rate` / `download_rate`,详见《[带宽限速](13-bandwidth.md)》)、
|
||||||
- v3.1:站点对外域名(`site_domain`)、自定义提取码(5~8 位)本文档与 `server/internal/api/` 实际实现逐一对齐,
|
站点对外域名(`site_domain`)、自定义提取码(5~8 位)
|
||||||
|
|
||||||
|
本文档与 `server/internal/api/` 实际实现逐一对齐,
|
||||||
交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。
|
交互式规范见站内 `/openapi`(源文件 `docs/openapi.yaml`)。
|
||||||
|
|
||||||
## Base URL
|
## Base URL
|
||||||
@@ -98,7 +100,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 站点 Logo 自定义 | [12-logo.md](12-logo.md) |
|
| 站点 Logo 自定义 | [12-logo.md](12-logo.md) |
|
||||||
| 错误码 | [11-errors.md](11-errors.md) |
|
| 错误码 | [11-errors.md](11-errors.md) |
|
||||||
| 带宽限速(v3.2) | [13-bandwidth.md](13-bandwidth.md) |
|
| 带宽限速(26.9) | [13-bandwidth.md](13-bandwidth.md) |
|
||||||
|
|
||||||
## 时间与编码
|
## 时间与编码
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
- `day`/`hour`/`minute`:按时间过期,`expired_count = -1`。
|
- `day`/`hour`/`minute`:按时间过期,`expired_count = -1`。
|
||||||
- `count`:按次数过期,取件 `expire_value` 次后失效(`expired_count = expire_value`);
|
- `count`:按次数过期,取件 `expire_value` 次后失效(`expired_count = expire_value`);
|
||||||
**v2 需求 ④**:`max_save_count>0` 时 `expire_value` 不得超出该上限,超限 403。
|
**26.9 需求 ④**:`max_save_count>0` 时 `expire_value` 不得超出该上限,超限 403。
|
||||||
- `forever`:永久(需站点允许;`max_save_seconds>0` 时其他方式受最长保存上限约束,超限 403)。
|
- `forever`:永久(需站点允许;`max_save_seconds>0` 时其他方式受最长保存上限约束,超限 403)。
|
||||||
|
|
||||||
> 可选值与上限来自公开配置 `GET /api/v1/config`(`expireStyle`、`max_save_seconds`、
|
> 可选值与上限来自公开配置 `GET /api/v1/config`(`expireStyle`、`max_save_seconds`、
|
||||||
@@ -88,4 +88,4 @@ curl -s "http://localhost:8466/share/select?code=8XQ2M"
|
|||||||
你好,文件快传
|
你好,文件快传
|
||||||
```
|
```
|
||||||
|
|
||||||
**v3.1 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
|
**26.9 变更**:① 支持 JSON 提交(`Content-Type: application/json`,字段同名);② 空文本 400「分享内容不能为空」;③ 可选 `code` 自定义提取码(5-8 位字母数字,占用 400)。
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ curl -s -X POST http://localhost:8466/share/file \
|
|||||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||||
```
|
```
|
||||||
|
|
||||||
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||||
> **下一次上传立即按新上限执行**,无需重启;上限值可经 `GET /api/v1/config` 的
|
> **下一次上传立即按新上限执行**,无需重启;上限值可经 `GET /api/v1/config` 的
|
||||||
> `max_file_size`/`maxFileSize` 字段读取。
|
> `max_file_size`/`maxFileSize` 字段读取。
|
||||||
|
|
||||||
@@ -101,3 +101,31 @@ curl -s -H 'Range: bytes=0-1023' -o part.bin \
|
|||||||
```json
|
```json
|
||||||
{ "code": 416, "msg": "请求范围超出文件大小" }
|
{ "code": 416, "msg": "请求范围超出文件大小" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 直链下载(26.9)
|
||||||
|
|
||||||
|
存储引擎为对象存储(S3)且 `direct_download=1` 时,`GET /share/select` 与
|
||||||
|
`GET /share/download` 不再代理文件流,而是 `302` 重定向到限时预签名 URL——
|
||||||
|
文件字节不经过本服务器,带宽成本转嫁对象存储。
|
||||||
|
|
||||||
|
- 签名有效期 = `direct_link_expire`(默认 900 秒)与分享剩余时效的较小值;
|
||||||
|
- 引擎不支持直链(如 local/WebDAV)时自动回落代理下载,取件不中断;
|
||||||
|
- 审计照常记录(`transferred_bytes` 记为文件大小)。
|
||||||
|
|
||||||
|
## 下载防盗链(26.9)
|
||||||
|
|
||||||
|
`hotlink_enabled=1` 时,`/share/download` 校验 `Referer`:
|
||||||
|
|
||||||
|
| Referer | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 空(直接访问 / curl / 地址栏) | 放行 |
|
||||||
|
| 与请求 Host 同源 | 放行 |
|
||||||
|
| 命中 `hotlink_whitelist`(逗号分隔域名,支持 `*.example.com` 通配) | 放行 |
|
||||||
|
| 其余 | `403`(JSON 错误体) |
|
||||||
|
|
||||||
|
白名单为空时仅同源放行。开关与白名单均为管理端 KV,修改后立即生效。
|
||||||
|
|
||||||
|
## 文件夹上传(26.9)
|
||||||
|
|
||||||
|
不支持文件夹上传(前端已移除目录选择;拖拽目录会提示"建议压缩后上传")。
|
||||||
|
后端 `SanitizeFileName` 会剥离文件名中的路径分隔符,多级路径无法成体保存。
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ curl -s -X POST http://localhost:8466/chunk/upload/3f6b8c2a4d5e6f708192a3b4c5d6e
|
|||||||
|
|
||||||
> 约束:单分片 ≤ `chunk_size`(init 声明值)且 ≤ **32MiB 硬上限**(init 时 `chunk_size>33554432` 直接 400「chunk_size 过大」);
|
> 约束:单分片 ≤ `chunk_size`(init 声明值)且 ≤ **32MiB 硬上限**(init 时 `chunk_size>33554432` 直接 400「chunk_size 过大」);
|
||||||
> 总大小(init 按分片数上限、上传/合并按累计)受**动态策略上限**约束——`max_file_size>0` 时为其,
|
> 总大小(init 按分片数上限、上传/合并按累计)受**动态策略上限**约束——`max_file_size>0` 时为其,
|
||||||
> 否则回落 `uploadSize`(v2 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
|
> 否则回落 `uploadSize`(26.9 需求 ④⑩,管理端改后立即生效,超限清理会话);首个分片做 magic bytes
|
||||||
> 防伪校验(403「文件内容校验失败:…」)。分片哈希由服务端计算;合并时与分片记录交叉校验,不一致报 400。
|
> 防伪校验(403「文件内容校验失败:…」)。分片哈希由服务端计算;合并时与分片记录交叉校验,不一致报 400。
|
||||||
>
|
>
|
||||||
> **enableChunk 开关**:管理端关闭分片上传后,`/chunk/upload/*` 全部端点返回 403「分片上传未启用」(后端强制,前端仅隐藏入口)。
|
> **enableChunk 开关**:管理端关闭分片上传后,`/chunk/upload/*` 全部端点返回 403「分片上传未启用」(后端强制,前端仅隐藏入口)。
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ curl -s -X POST http://localhost:8466/presign/upload/init \
|
|||||||
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
{ "code": 403, "msg": "大小超过限制,最大为10.00 MB" }
|
||||||
```
|
```
|
||||||
|
|
||||||
> 大小上限为动态策略(v2 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
> 大小上限为动态策略(26.9 需求 ④⑩):管理端改 `max_file_size`(0=回落 `uploadSize`)后
|
||||||
> 立即按新上限校验 init 声明的 `file_size`。
|
> 立即按新上限校验 init 声明的 `file_size`。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+23
-6
@@ -318,7 +318,7 @@ curl -s "http://localhost:8466/admin/file/preview?id=41&maxChars=100" -H "Author
|
|||||||
|
|
||||||
返回运行时配置 KV(含默认值与管理端修改)。`admin_token` 恒返回空串(屏蔽);
|
返回运行时配置 KV(含默认值与管理端修改)。`admin_token` 恒返回空串(屏蔽);
|
||||||
`jwt_secret` 不下发;存储引擎为进程级单例,`_engine_hint` 提示引擎配置修改需重启。
|
`jwt_secret` 不下发;存储引擎为进程级单例,`_engine_hint` 提示引擎配置修改需重启。
|
||||||
v2 新增键(需求 ①②③④⑩)一并返回:`background_url`、`footer_text`、`footer_beian`、
|
26.9 新增键(需求 ①②③④⑩)一并返回:`background_url`、`footer_text`、`footer_beian`、
|
||||||
`notify_enabled`、`max_save_count`、`max_file_size` 等。
|
`notify_enabled`、`max_save_count`、`max_file_size` 等。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -351,7 +351,7 @@ v2 新增键(需求 ①②③④⑩)一并返回:`background_url`、`foote
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 存储引擎热切换:POST /admin/storage/switch(v3)
|
## 存储引擎热切换:POST /admin/storage/switch(26.9)
|
||||||
|
|
||||||
运行时切换存储引擎,**无需重启**:
|
运行时切换存储引擎,**无需重启**:
|
||||||
|
|
||||||
@@ -374,14 +374,14 @@ curl -s -X POST http://localhost:8466/admin/storage/switch \
|
|||||||
部分更新(JSON 对象,未提供的键不变;表单亦可)。**PATCH 为主名,POST 为兼容别名**。
|
部分更新(JSON 对象,未提供的键不变;表单亦可)。**PATCH 为主名,POST 为兼容别名**。
|
||||||
|
|
||||||
- 仅接受管理端可见键(见上响应键集合),未知键忽略。
|
- 仅接受管理端可见键(见上响应键集合),未知键忽略。
|
||||||
- 数值型键自动转型:`openUpload`、`enableChunk`、`uploadSize`、`storageLimit`、限流四组、`max_save_seconds`、`adminSessionExpire`、`showAdminAddr`;v2 新增 `max_save_count`、`max_file_size`、`notify_enabled`;`opacity` 为浮点。
|
- 数值型键自动转型:`openUpload`、`enableChunk`、`uploadSize`、`storageLimit`、限流四组、`max_save_seconds`、`adminSessionExpire`、`showAdminAddr`;26.9 新增 `max_save_count`、`max_file_size`、`notify_enabled`;`opacity` 为浮点。
|
||||||
- **v3.1**:`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
|
- **26.9**:`site_domain`(站点对外域名)可经本端点设置,非法格式 400(仅 http/https、主机+端口、不带路径)。
|
||||||
- **v3 引擎键**:`storage_engine` 不经本端点修改(走 `POST /admin/storage/switch`);引擎参数键
|
- **26.9 引擎键**:`storage_engine` 不经本端点修改(走 `POST /admin/storage/switch`);引擎参数键
|
||||||
(`local_storage_path`、`webdav_url`、`webdav_root_path`、`webdav_username`、`webdav_password`、
|
(`local_storage_path`、`webdav_url`、`webdav_root_path`、`webdav_username`、`webdav_password`、
|
||||||
`s3_endpoint_url`、`s3_region_name`、`s3_bucket_name`、`s3_access_key_id`、`s3_secret_access_key`、
|
`s3_endpoint_url`、`s3_region_name`、`s3_bucket_name`、`s3_access_key_id`、`s3_secret_access_key`、
|
||||||
`aws_session_token`、`s3_addressing_style`)可经本端点保存——保存后对应引擎实例缓存失效,
|
`aws_session_token`、`s3_addressing_style`)可经本端点保存——保存后对应引擎实例缓存失效,
|
||||||
下次切换/构建生效;敏感键空串或 `******` 表示不修改。
|
下次切换/构建生效;敏感键空串或 `******` 表示不修改。
|
||||||
- **v2 schema 校验**(`settings.KVSchema`,越界一律 400,中文错误信息):
|
- **26.9 schema 校验**(`settings.KVSchema`,越界一律 400,中文错误信息):
|
||||||
- 整型边界:`max_file_size` ≤ 10GiB(10737418240)、`max_save_count` ≤ 100000、`max_save_seconds` ≤ 31536000(365 天)、`notify_enabled` ∈ {0,1}、`uploadCount` 1~10000、`uploadMinute` 1~1440 等;
|
- 整型边界:`max_file_size` ≤ 10GiB(10737418240)、`max_save_count` ≤ 100000、`max_save_seconds` ≤ 31536000(365 天)、`notify_enabled` ∈ {0,1}、`uploadCount` 1~10000、`uploadMinute` 1~1440 等;
|
||||||
- 字符串长度:`background_url` ≤ 2048、`footer_text` ≤ 2000、`footer_beian` ≤ 128、`notify_title` ≤ 128、`notify_content` ≤ 2000 字符;
|
- 字符串长度:`background_url` ≤ 2048、`footer_text` ≤ 2000、`footer_beian` ≤ 128、`notify_title` ≤ 128、`notify_content` ≤ 2000 字符;
|
||||||
- 列表键 `expireStyle` / `allowed_file_types`:须为字符串数组(或逗号分隔串)且至少保留一项;
|
- 列表键 `expireStyle` / `allowed_file_types`:须为字符串数组(或逗号分隔串)且至少保留一项;
|
||||||
@@ -432,3 +432,20 @@ curl -s -X PATCH http://localhost:8466/admin/settings/password \
|
|||||||
```json
|
```json
|
||||||
{ "code": 401, "msg": "旧密码错误" }
|
{ "code": 401, "msg": "旧密码错误" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 手动回收:POST /admin/recycle/run
|
||||||
|
|
||||||
|
**26.9**:手动触发一轮过期分享回收(定时循环之外的管理端入口)。回收范围:
|
||||||
|
时间已过期、次数已耗尽、创建时间超过 `retention_days` 的分享——删除记录并
|
||||||
|
连带删除存储对象(SHA512 去重开启时做引用计数,仍有其他分享引用的对象保留)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "http://localhost:8466/admin/recycle/run" -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "msg": "ok", "data": { "removed": 3 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
相关配置键:`recycle_enabled`(定时开关)、`recycle_interval`(扫描间隔)、
|
||||||
|
`retention_days`(最长存储时长)、`dedup_enabled`(引用计数开关)。
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ FCB_STORAGE_ENGINE=webdav
|
|||||||
|
|
||||||
非法值直接启动失败:`FCB_STORAGE_ENGINE 无效值 "xxx",仅支持 local|s3|webdav`。
|
非法值直接启动失败:`FCB_STORAGE_ENGINE 无效值 "xxx",仅支持 local|s3|webdav`。
|
||||||
|
|
||||||
**v3 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
|
**26.9 运行时热切换**:管理端 `POST /admin/storage/switch`(或后台设置页「存储引擎」卡)可在不重启的情况下切换引擎——
|
||||||
先构建新引擎并健康检查,通过才生效;失败 503 保持原引擎。当前引擎持久化在 settings KV `storage_engine`(空=回落启动值)。
|
先构建新引擎并健康检查,通过才生效;失败 503 保持原引擎。当前引擎持久化在 settings KV `storage_engine`(空=回落启动值)。
|
||||||
各引擎参数(存储目录/服务地址/存储桶/密钥)同样在后台设置页运行时可改;保存后对应引擎实例缓存失效,下次切换/构建生效。
|
各引擎参数(存储目录/服务地址/存储桶/密钥)同样在后台设置页运行时可改;保存后对应引擎实例缓存失效,下次切换/构建生效。
|
||||||
|
|
||||||
## 文件归属引擎(v3)
|
## 文件归属引擎(26.9)
|
||||||
|
|
||||||
每条分享记录(`file_codes.engine`)与上传会话(`upload_chunks.engine` / `presign_upload_sessions.engine`)
|
每条分享记录(`file_codes.engine`)与上传会话(`upload_chunks.engine` / `presign_upload_sessions.engine`)
|
||||||
在创建时戳记当时的引擎名。下载、分片合并、删除按**归属引擎**操作——切换引擎后,旧引擎里的文件仍可正常下载与删除
|
在创建时戳记当时的引擎名。下载、分片合并、删除按**归属引擎**操作——切换引擎后,旧引擎里的文件仍可正常下载与删除
|
||||||
|
|||||||
+24
-16
@@ -1,7 +1,7 @@
|
|||||||
# 环境变量与配置项
|
# 环境变量与配置项
|
||||||
|
|
||||||
配置分三层:**默认值 → `FCB_*` 环境变量 → 数据库 settings KV(管理端运行时修改)**。
|
配置分三层:**默认值 → `FCB_*` 环境变量 → 数据库 settings KV(管理端运行时修改)**。
|
||||||
v2 起进程必需的环境变量为空集:数据库默认 **SQLite**(modernc.org/sqlite 纯 Go 驱动,零外部依赖,
|
26.9 起进程必需的环境变量为空集:数据库默认 **SQLite**(modernc.org/sqlite 纯 Go 驱动,零外部依赖,
|
||||||
DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DSN` 必需(需求 ⑧)。
|
DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DSN` 必需(需求 ⑧)。
|
||||||
|
|
||||||
## 环境变量(进程级)
|
## 环境变量(进程级)
|
||||||
@@ -31,14 +31,14 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
## 配置项(settings KV,默认值对齐参考实现)
|
## 配置项(settings KV,默认值对齐参考实现)
|
||||||
|
|
||||||
> 键名/类型/默认值/边界以 `server/internal/config/schema.go` 的 `KVSchema()` 为单一事实来源
|
> 键名/类型/默认值/边界以 `server/internal/config/schema.go` 的 `KVSchema()` 为单一事实来源
|
||||||
> (schema 同步测试保证与 defaults() 逐键一致);v2 新增键统一 snake_case。
|
> (schema 同步测试保证与 defaults() 逐键一致);26.9 新增键统一 snake_case。
|
||||||
|
|
||||||
### 站点信息与展示(需求 ①②③)
|
### 站点信息与展示(需求 ①②③)
|
||||||
|
|
||||||
| 键 | 类型/边界 | 默认 | 说明 |
|
| 键 | 类型/边界 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `site_name` / `name` | string | 文件快传 | 站点名称(`site_name` 优先) |
|
| `site_name` / `name` | string | 文件快传 | 站点名称(`site_name` 优先) |
|
||||||
| `site_domain` | string,≤256 | 空 | **v3.1**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
|
| `site_domain` | string,≤256 | 空 | **26.9**:站点对外域名(`http(s)://host[:port]`,不带路径;裸主机自动补 `http://`)。配置后分享链接(结果卡/管理端复制)用该域名生成——内网部署也能把公网链接发出去;留空=用当前访问地址 |
|
||||||
| `description` | string | 开箱即用的文件快传系统 | 站点描述 |
|
| `description` | string | 开箱即用的文件快传系统 | 站点描述 |
|
||||||
| `page_explain` | string | (合规声明) | 页面说明文案 |
|
| `page_explain` | string | (合规声明) | 页面说明文案 |
|
||||||
| `keywords` | string | 文件快传, 文件分享… | SEO 关键词 |
|
| `keywords` | string | 文件快传, 文件分享… | SEO 关键词 |
|
||||||
@@ -46,10 +46,10 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
| `favicon_url` | string | 空(前端回落本地打包 `/assets/favicon-*.png`,需求 ⑤) | favicon / 备用 Logo |
|
| `favicon_url` | string | 空(前端回落本地打包 `/assets/favicon-*.png`,需求 ⑤) | favicon / 备用 Logo |
|
||||||
| `opacity` | float | 0.9 | 界面不透明度 |
|
| `opacity` | float | 0.9 | 界面不透明度 |
|
||||||
| `background` | string | 空 | 背景图 URL(参考实现既有键,v1 兼容保留) |
|
| `background` | string | 空 | 背景图 URL(参考实现既有键,v1 兼容保留) |
|
||||||
| `background_url` | string,≤2048 字符 | 空 | **v2 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)`、`data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400) |
|
| `background_url` | string,≤2048 字符 | 空 | **26.9 需求 ①**:背景图 URL 或上传后地址(空=主题默认;取值时 legacy `background` 键兜底)。管理端保存时校验协议白名单:仅 `http(s)`、`data:image/*` 与站内相对路径(防 `javascript:` 注入,非法 400) |
|
||||||
| `footer_text` | string,≤2000 字符 | 空 | **v2 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
|
| `footer_text` | string,≤2000 字符 | 空 | **26.9 需求 ②**:页脚自定义内容(纯文本或受控 HTML 片段) |
|
||||||
| `footer_beian` | string,≤128 字符 | 空 | **v2 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
|
| `footer_beian` | string,≤128 字符 | 空 | **26.9 需求 ②**:备案号(如 `京ICP备2024xxxxxx号-1`),展示于页脚 |
|
||||||
| `notify_enabled` | int(0/1) | 1 | **v2 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
|
| `notify_enabled` | int(0/1) | 1 | **26.9 需求 ③**:通知开关(1=前台右上角悬浮窗展示 / 0=关闭) |
|
||||||
| `notify_title` | string,≤128 字符 | 系统通知 | 通知标题 |
|
| `notify_title` | string,≤128 字符 | 系统通知 | 通知标题 |
|
||||||
| `notify_content` | string,≤2000 字符 | 欢迎使用… | 通知正文(**服务端白名单净化**:仅保留纯文本与 `<a href>` 为 http(s)/站内相对/`#` 锚点的链接,其余标签与事件属性剥离,保存与读取双侧生效) |
|
| `notify_content` | string,≤2000 字符 | 欢迎使用… | 通知正文(**服务端白名单净化**:仅保留纯文本与 `<a href>` 为 http(s)/站内相对/`#` 锚点的链接,其余标签与事件属性剥离,保存与读取双侧生效) |
|
||||||
| `showAdminAddr` | int(0/1) | 0 | 是否展示后台入口 |
|
| `showAdminAddr` | int(0/1) | 0 | 是否展示后台入口 |
|
||||||
@@ -59,8 +59,8 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
|
|
||||||
| 键 | 类型/边界 | 默认 | 说明 |
|
| 键 | 类型/边界 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `max_save_seconds` | int64,0~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**v3**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
|
| `max_save_seconds` | int64,0~31536000 | 0 | 最长保存秒数上限(0=仅默认 7 天兜底;>0 时按时间过期超限 403「限制最长时间为 X,可换用其他方式」)。**26.9**:管理界面以「小时/天」下拉单位编辑(≥1 天自动显示天),提交时前端换算为秒——canonical 单位保持秒,接口语义不变 |
|
||||||
| `max_save_count` | int,0~100000 | 0 | **v2 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count` 且 `expire_value` 超上限时 403「限制次数最多为 N 次」) |
|
| `max_save_count` | int,0~100000 | 0 | **26.9 新增**:单次分享最大可取(保存)次数上限(0=不限制;`expire_style=count` 且 `expire_value` 超上限时 403「限制次数最多为 N 次」) |
|
||||||
| `expireStyle` | []string | `["day","hour","minute","forever","count"]` | 允许的过期方式白名单(上传时不在白名单 400「过期时间类型错误」) |
|
| `expireStyle` | []string | `["day","hour","minute","forever","count"]` | 允许的过期方式白名单(上传时不在白名单 400「过期时间类型错误」) |
|
||||||
|
|
||||||
### 存储策略(需求 ④⑩)
|
### 存储策略(需求 ④⑩)
|
||||||
@@ -68,7 +68,7 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
| 键 | 类型/边界 | 默认 | 说明 |
|
| 键 | 类型/边界 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `uploadSize` | int64,1024~10GiB | 10485760(10MB) | 单文件大小上限(字节),参考实现语义;`max_file_size=0` 时作为生效上限 |
|
| `uploadSize` | int64,1024~10GiB | 10485760(10MB) | 单文件大小上限(字节),参考实现语义;`max_file_size=0` 时作为生效上限 |
|
||||||
| `max_file_size` | int64,0~10GiB | 0 | **v2 新增**:存储策略-单文件上限(字节),0=回落 `uploadSize`;超出 403(文案 humanSize 自适应 B/KB/MB/GB)。**v3**:管理界面以「MB/GB」下拉单位编辑(≥1 GiB 自动显示 GB),提交时前端换算为字节 |
|
| `max_file_size` | int64,0~10GiB | 0 | **26.9 新增**:存储策略-单文件上限(字节),0=回落 `uploadSize`;超出 403(文案 humanSize 自适应 B/KB/MB/GB)。**26.9**:管理界面以「MB/GB」下拉单位编辑(≥1 GiB 自动显示 GB),提交时前端换算为字节 |
|
||||||
| `allowed_file_types` | []string | `["*"]` | 允许类型白名单(扩展名/MIME 通配,`*` 不限制;非白名单 403「不允许上传该类型文件」) |
|
| `allowed_file_types` | []string | `["*"]` | 允许类型白名单(扩展名/MIME 通配,`*` 不限制;非白名单 403「不允许上传该类型文件」) |
|
||||||
| `storageLimit` | int64,≥0 | 0 | 站点总容量(字节),0=不限制(超限 507) |
|
| `storageLimit` | int64,≥0 | 0 | 站点总容量(字节),0=不限制(超限 507) |
|
||||||
| `openUpload` | int(0/1) | 1 | 游客上传开关(0 时上传接口要求管理员令牌 403) |
|
| `openUpload` | int(0/1) | 1 | 游客上传开关(0 时上传接口要求管理员令牌 403) |
|
||||||
@@ -79,7 +79,15 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
| 键 | 类型/边界 | 默认 | 说明 |
|
| 键 | 类型/边界 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `uploadCount` / `uploadMinute` | int(1~10000 / 1~1440) | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) |
|
| `uploadCount` / `uploadMinute` | int(1~10000 / 1~1440) | 10 / 1 | 窗口内允许上传次数 / 窗口分钟(上传成功才计数,超限 423;管理端修改后运行时同步限流规则,立即生效) |
|
||||||
| `upload_rate` / `download_rate` | int64(0~1 GiB/s) | 0 / 0 | **v3.2**:上下行带宽字节/秒,0=不限速;管理端改后立即生效(每请求动态读 KV)。详见《[带宽限速](13-bandwidth.md)》 |
|
| `upload_rate` / `download_rate` | int64(0~1 GiB/s) | 0 / 0 | **26.9**:上下行带宽字节/秒,0=不限速;管理端改后立即生效(每请求动态读 KV)。详见《[带宽限速](13-bandwidth.md)》 |
|
||||||
|
| `recycle_enabled` | 0/1 | 1 | **26.9**:过期分享自动回收开关(定时扫描 + 取件惰性回收) |
|
||||||
|
| `recycle_interval` | int64(60~86400 秒) | 1800 | **26.9**:回收扫描间隔(秒;管理端以分钟展示) |
|
||||||
|
| `retention_days` | int64(0~3650 天) | 0 | **26.9**:最长存储时长(天),上传超过该天数的分享自动回收;0=不限制 |
|
||||||
|
| `dedup_enabled` | 0/1 | 1 | **26.9**:SHA512 内容去重,相同文件仅存储一份(多分享引用同一对象,引用计数删除) |
|
||||||
|
| `direct_download` | 0/1 | 1 | **26.9**:对象存储直链下载(S3 引擎 302 到预签名 URL,文件不经过本站带宽) |
|
||||||
|
| `direct_link_expire` | int64(60~3600 秒) | 900 | **26.9**:直链签名有效期(秒;不超过分享剩余时效) |
|
||||||
|
| `hotlink_enabled` | 0/1 | 0 | **26.9**:下载防盗链(Referer 白名单校验;空 Referer 放行) |
|
||||||
|
| `hotlink_whitelist` | string(≤2048) | 空 | **26.9**:防盗链白名单,逗号分隔域名,支持 `*.example.com` 通配;空=仅同源放行 |
|
||||||
| `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 |
|
| `errorCount` / `errorMinute` | int | 10 / 1 | 取件错误(失败计数)+ metadata 每次计数 |
|
||||||
| `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 |
|
| `loginCount` / `loginMinute` | int | 5 / 15 | 登录失败计数 |
|
||||||
|
|
||||||
@@ -91,9 +99,9 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
| `jwt_secret` | 空 | JWT 签名密钥(初始化/改密时自动生成轮换;不下发;`settings.SensitiveKeys` 双模式下一致屏蔽) |
|
| `jwt_secret` | 空 | JWT 签名密钥(初始化/改密时自动生成轮换;不下发;`settings.SensitiveKeys` 双模式下一致屏蔽) |
|
||||||
| `adminSessionExpire` | 604800(7 天) | 管理员会话秒数(须 1~365 整天) |
|
| `adminSessionExpire` | 604800(7 天) | 管理员会话秒数(须 1~365 整天) |
|
||||||
|
|
||||||
### 存储引擎(v3 运行时可配 + 热切换)
|
### 存储引擎(26.9 运行时可配 + 热切换)
|
||||||
|
|
||||||
**`storage_engine`**(v3 新增键):string,`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`。
|
**`storage_engine`**(26.9 新增键):string,`local|s3|webdav`,默认空=回落启动值 `FCB_STORAGE_ENGINE`。
|
||||||
运行时切换走 **`POST /admin/storage/switch`**(JWT 保护):构建新引擎 → 健康检查通过才生效;
|
运行时切换走 **`POST /admin/storage/switch`**(JWT 保护):构建新引擎 → 健康检查通过才生效;
|
||||||
失败返回 503「存储引擎切换失败,已保持原引擎: …」且不改 KV。成功后持久化 `storage_engine`,重启沿用。
|
失败返回 503「存储引擎切换失败,已保持原引擎: …」且不改 KV。成功后持久化 `storage_engine`,重启沿用。
|
||||||
`GET /api/v1/config` 公开下发 `storage_engine` 当前名(仅名称,任何引擎参数/凭据不下发)。
|
`GET /api/v1/config` 公开下发 `storage_engine` 当前名(仅名称,任何引擎参数/凭据不下发)。
|
||||||
@@ -115,10 +123,10 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
| `webdav_root_path` | `filebox_storage` |
|
| `webdav_root_path` | `filebox_storage` |
|
||||||
| `webdav_proxy` | 0 |
|
| `webdav_proxy` | 0 |
|
||||||
|
|
||||||
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`(v3 加入 `settings.SensitiveKeys`):
|
> 敏感键 `webdav_password` / `s3_secret_access_key` / `aws_session_token`(26.9 加入 `settings.SensitiveKeys`):
|
||||||
> 管理端 GET 返回掩码 `******`;PATCH 时空串或 `******` 表示不修改。直接写库 settings KV 后重启同样生效。
|
> 管理端 GET 返回掩码 `******`;PATCH 时空串或 `******` 表示不修改。直接写库 settings KV 后重启同样生效。
|
||||||
|
|
||||||
## 策略动态生效机制(v2 需求 ④⑩)
|
## 策略动态生效机制(26.9 需求 ④⑩)
|
||||||
|
|
||||||
上传页通过 `GET /api/v1/config` 的 `config` 字段读取**当前策略快照**并在范围内渲染选项;
|
上传页通过 `GET /api/v1/config` 的 `config` 字段读取**当前策略快照**并在范围内渲染选项;
|
||||||
上传链路(`/share/file`、`/chunk/*`、`/presign/*`)**每次请求实时读取** settings KV 同一组值校验:
|
上传链路(`/share/file`、`/chunk/*`、`/presign/*`)**每次请求实时读取** settings KV 同一组值校验:
|
||||||
@@ -129,7 +137,7 @@ DSN 缺省落 `./data/fileshare.db`);`FCB_DB_DRIVER=postgres` 时 `FCB_DB_DS
|
|||||||
|
|
||||||
## 公共配置接口
|
## 公共配置接口
|
||||||
|
|
||||||
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;v2 扩展需求 ①②③④⑩):
|
前端启动时经 `GET /api/v1/config` 获取站点公开配置(无需认证;26.9 扩展需求 ①②③④⑩):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
# Logo 自定义
|
# Logo 自定义
|
||||||
|
|
||||||
## 默认 Logo(内置,v2 需求 ⑤)
|
## 默认 Logo(内置,26.9 需求 ⑤)
|
||||||
|
|
||||||
| 项 | 默认值 | 用途 |
|
| 项 | 默认值 | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 页面导航 Logo | 前端打包本地资源 `/assets/logo-*.svg`(源:`web/src/assets/brand/logo.svg`) | 导航栏 `<img>`;`config.logo_url` 为空时回落使用 |
|
| 页面导航 Logo | 前端打包本地资源 `/assets/logo-*.svg`(源:`web/src/assets/brand/logo.svg`) | 导航栏 `<img>`;`config.logo_url` 为空时回落使用 |
|
||||||
| favicon / 备用 Logo | 前端打包本地资源 `/assets/favicon-*.png`(源:`web/src/assets/brand/favicon.png`) | `index.html` `<link rel="icon">` + 动态 favicon 回落 |
|
| favicon / 备用 Logo | 前端打包本地资源 `/assets/favicon-*.png`(源:`web/src/assets/brand/favicon.png`) | `index.html` `<link rel="icon">` + 动态 favicon 回落 |
|
||||||
|
|
||||||
v2 起默认不再引用远程 URL:`GET /api/v1/config` 中 `logo_url`/`favicon_url` 默认下发空串,
|
26.9 起默认不再引用远程 URL:`GET /api/v1/config` 中 `logo_url`/`favicon_url` 默认下发空串,
|
||||||
前端 `displayLogoUrl`/`displayFaviconUrl` 判空后回落到打包的本地资源。
|
前端 `displayLogoUrl`/`displayFaviconUrl` 判空后回落到打包的本地资源。
|
||||||
管理端仍可设置任意 URL 全站替换(三步如下)。
|
管理端仍可设置任意 URL 全站替换(三步如下)。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 带宽限速(v3.2)
|
# 带宽限速(26.9)
|
||||||
|
|
||||||
> v3.2 新增。管理端可独立设置**上行(上传)/ 下行(下载)带宽**,单位字节/秒(前端 UI 友好单位为 MB/s),
|
> 26.9 新增。管理端可独立设置**上行(上传)/ 下行(下载)带宽**,单位字节/秒(前端 UI 友好单位为 MB/s),
|
||||||
> 0=不限速。修改后**立即生效**(每请求动态读取最新 KV,不需重启容器/进程)。
|
> 0=不限速。修改后**立即生效**(每请求动态读取最新 KV,不需重启容器/进程)。
|
||||||
|
|
||||||
## 适用对象
|
## 适用对象
|
||||||
@@ -38,7 +38,7 @@ return n, err
|
|||||||
|
|
||||||
保证长期速率严格 ≤ `rate`,瞬时由调用方 Read 块大小自然突发。**不按字节微睡眠**——避免单次 Read 在低速场景下被调度抖动放大。
|
保证长期速率严格 ≤ `rate`,瞬时由调用方 Read 块大小自然突发。**不按字节微睡眠**——避免单次 Read 在低速场景下被调度抖动放大。
|
||||||
|
|
||||||
> 早期版本按每字节 sleep 1/rate 秒,被 Mac 调度粒度(约 10µs)放大后实际速率只有目标值的 1/3;v3.2.1 修正为整块对齐 sleep。
|
> 早期版本按每字节 sleep 1/rate 秒,被 Mac 调度粒度(约 10µs)放大后实际速率只有目标值的 1/3;26.9 修正为整块对齐 sleep。
|
||||||
|
|
||||||
## 公开接口
|
## 公开接口
|
||||||
|
|
||||||
|
|||||||
+49
-6
@@ -93,7 +93,7 @@ paths:
|
|||||||
footer_text: { type: string, description: '需求 ② 页脚自定义内容' }
|
footer_text: { type: string, description: '需求 ② 页脚自定义内容' }
|
||||||
footer_beian: { type: string, description: '需求 ② 备案号' }
|
footer_beian: { type: string, description: '需求 ② 备案号' }
|
||||||
storage_engine: { type: string, description: 当前存储引擎名(仅名称,凭据不下发) }
|
storage_engine: { type: string, description: 当前存储引擎名(仅名称,凭据不下发) }
|
||||||
site_domain: { type: string, description: 'v3.1 站点对外域名(空=用当前访问地址)' }
|
site_domain: { type: string, description: '26.9 站点对外域名(空=用当前访问地址)' }
|
||||||
notify_enabled: { type: integer, enum: [0, 1], description: '需求 ③ 通知开关(1=右上角悬浮窗)' }
|
notify_enabled: { type: integer, enum: [0, 1], description: '需求 ③ 通知开关(1=右上角悬浮窗)' }
|
||||||
notify_title: { type: string }
|
notify_title: { type: string }
|
||||||
notify_content: { type: string }
|
notify_content: { type: string }
|
||||||
@@ -166,8 +166,16 @@ paths:
|
|||||||
loginMinute: { type: integer, default: 15 }
|
loginMinute: { type: integer, default: 15 }
|
||||||
uploadCount: { type: integer, default: 10 }
|
uploadCount: { type: integer, default: 10 }
|
||||||
uploadMinute: { type: integer, default: 1 }
|
uploadMinute: { type: integer, default: 1 }
|
||||||
upload_rate: { type: integer, format: int64, default: 0, description: 'v3.2 上行带宽字节/秒;0=不限速' }
|
upload_rate: { type: integer, format: int64, default: 0, description: '26.9 上行带宽字节/秒;0=不限速' }
|
||||||
download_rate: { type: integer, format: int64, default: 0, description: 'v3.2 下行带宽字节/秒;0=不限速' }
|
download_rate: { type: integer, format: int64, default: 0, description: '26.9 下行带宽字节/秒;0=不限速' }
|
||||||
|
recycle_enabled: { type: integer, enum: [0, 1], default: 1, description: '26.9 过期分享自动回收开关' }
|
||||||
|
recycle_interval: { type: integer, format: int64, default: 1800, description: '26.9 回收扫描间隔秒(60~86400)' }
|
||||||
|
retention_days: { type: integer, format: int64, default: 0, description: '26.9 最长存储时长天(0~3650;0=不限制)' }
|
||||||
|
dedup_enabled: { type: integer, enum: [0, 1], default: 1, description: '26.9 SHA512 内容去重开关' }
|
||||||
|
direct_download: { type: integer, enum: [0, 1], default: 1, description: '26.9 对象存储直链下载开关' }
|
||||||
|
direct_link_expire: { type: integer, format: int64, default: 900, description: '26.9 直链签名有效期秒(60~3600)' }
|
||||||
|
hotlink_enabled: { type: integer, enum: [0, 1], default: 0, description: '26.9 下载防盗链开关' }
|
||||||
|
hotlink_whitelist: { type: string, maxLength: 2048, default: '', description: '26.9 防盗链白名单(逗号分隔域名,支持 *.example.com)' }
|
||||||
allowed_file_types: { type: string, default: '*' }
|
allowed_file_types: { type: string, default: '*' }
|
||||||
openUpload: { type: boolean, default: true }
|
openUpload: { type: boolean, default: true }
|
||||||
enableChunk: { type: boolean, default: false }
|
enableChunk: { type: boolean, default: false }
|
||||||
@@ -207,7 +215,7 @@ paths:
|
|||||||
文本 ≤222KB(请求体全局上限 1MiB,Content-Length>441KB 直接 403);上传类接口(成功计数 upload 限流,423 超限),经审计中间件落库。
|
文本 ≤222KB(请求体全局上限 1MiB,Content-Length>441KB 直接 403);上传类接口(成功计数 upload 限流,423 超限),经审计中间件落库。
|
||||||
保存策略(需求 ④):expire_style 须在 expireStyle 白名单(400);count 型受
|
保存策略(需求 ④):expire_style 须在 expireStyle 白名单(400);count 型受
|
||||||
max_save_count 约束(403「限制次数最多为 N 次」);时间型受 max_save_seconds 约束(403)。
|
max_save_count 约束(403「限制次数最多为 N 次」);时间型受 max_save_seconds 约束(403)。
|
||||||
v3.1:支持 JSON 提交(字段同名);空文本 400「分享内容不能为空」;
|
26.9:支持 JSON 提交(字段同名);空文本 400「分享内容不能为空」;
|
||||||
可选 code 自定义提取码(5-8 位字母或数字,占用 400「该提取码已被占用」)。
|
可选 code 自定义提取码(5-8 位字母或数字,占用 400「该提取码已被占用」)。
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
@@ -1377,8 +1385,16 @@ paths:
|
|||||||
storageLimit: { type: integer, format: int64 }
|
storageLimit: { type: integer, format: int64 }
|
||||||
uploadCount: { type: integer }
|
uploadCount: { type: integer }
|
||||||
uploadMinute: { type: integer }
|
uploadMinute: { type: integer }
|
||||||
upload_rate: { type: integer, format: int64, description: 'v3.2 上行带宽字节/秒;0=不限速' }
|
upload_rate: { type: integer, format: int64, description: '26.9 上行带宽字节/秒;0=不限速' }
|
||||||
download_rate: { type: integer, format: int64, description: 'v3.2 下行带宽字节/秒;0=不限速' }
|
download_rate: { type: integer, format: int64, description: '26.9 下行带宽字节/秒;0=不限速' }
|
||||||
|
recycle_enabled: { type: integer, enum: [0, 1], description: '26.9 过期分享自动回收开关' }
|
||||||
|
recycle_interval: { type: integer, format: int64, description: '26.9 回收扫描间隔秒(60~86400)' }
|
||||||
|
retention_days: { type: integer, format: int64, description: '26.9 最长存储时长天(0~3650)' }
|
||||||
|
dedup_enabled: { type: integer, enum: [0, 1], description: '26.9 SHA512 内容去重开关' }
|
||||||
|
direct_download: { type: integer, enum: [0, 1], description: '26.9 对象存储直链下载开关' }
|
||||||
|
direct_link_expire: { type: integer, format: int64, description: '26.9 直链签名有效期秒(60~3600)' }
|
||||||
|
hotlink_enabled: { type: integer, enum: [0, 1], description: '26.9 下载防盗链开关' }
|
||||||
|
hotlink_whitelist: { type: string, maxLength: 2048, description: '26.9 防盗链白名单' }
|
||||||
admin_token: { type: string, example: '' }
|
admin_token: { type: string, example: '' }
|
||||||
_engine_hint:
|
_engine_hint:
|
||||||
type: object
|
type: object
|
||||||
@@ -1529,6 +1545,33 @@ paths:
|
|||||||
"401": { $ref: "#/components/responses/Unauthorized" }
|
"401": { $ref: "#/components/responses/Unauthorized" }
|
||||||
"503": { $ref: "#/components/responses/ServiceUnavailable" }
|
"503": { $ref: "#/components/responses/ServiceUnavailable" }
|
||||||
|
|
||||||
|
/admin/recycle/run:
|
||||||
|
post:
|
||||||
|
tags: [admin]
|
||||||
|
summary: 手动触发一轮过期回收(26.9)
|
||||||
|
description: |
|
||||||
|
回收时间已过期、次数已耗尽、创建时间超过 retention_days 的分享:
|
||||||
|
删除记录并连带删除存储对象(dedup_enabled 开启时做引用计数,
|
||||||
|
仍有其他分享引用的对象保留)。返回本轮回收条数。
|
||||||
|
operationId: adminRecycleRun
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 回收完成
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/Envelope'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
removed: { type: integer, description: 本轮回收条数 }
|
||||||
|
"401": { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
|
||||||
/admin/settings/password:
|
/admin/settings/password:
|
||||||
patch:
|
patch:
|
||||||
tags: [管理后台]
|
tags: [管理后台]
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
- 位置:`server/internal/api/helpers.go:114-125`、`validatePickupCode`(4 位下限)
|
- 位置:`server/internal/api/helpers.go:114-125`、`validatePickupCode`(4 位下限)
|
||||||
- 原状:`code_generate_type=number` 时仅 9 万空间(5 位数字),默认 `errorCount=10/分/IP` 下单 IP 需约 6 天扫完,分布式多 IP 可显著缩短;自定义码允许 4 位(36⁴≈168 万)。
|
- 原状:`code_generate_type=number` 时仅 9 万空间(5 位数字),默认 `errorCount=10/分/IP` 下单 IP 需约 6 天扫完,分布式多 IP 可显著缩短;自定义码允许 4 位(36⁴≈168 万)。
|
||||||
- ✅ 修复:自定义提码最小长度 4 → **5 位**(`pickupCodeMinLen=5`,36⁵≈6000 万空间);测试 `v31_test.go` 与 `TestPickupCodeMinLen` 同步更新。
|
- ✅ 修复:自定义提码最小长度 4 → **5 位**(`pickupCodeMinLen=5`,36⁵≈6000 万空间);测试 `custom_code_test.go` 与 `TestPickupCodeMinLen` 同步更新。
|
||||||
|
|
||||||
### L4 `enableChunk` 开关后端不强制 ✅ 已修复
|
### L4 `enableChunk` 开关后端不强制 ✅ 已修复
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -11,7 +11,7 @@ server/
|
|||||||
├── cmd/server/main.go # 入口:装配 配置→DB→缓存→设置→审计→中间件→路由
|
├── cmd/server/main.go # 入口:装配 配置→DB→缓存→设置→审计→中间件→路由
|
||||||
└── internal/
|
└── internal/
|
||||||
├── config/ # 配置:FCB_* 环境变量基线 + DB settings KV 运行时覆盖
|
├── config/ # 配置:FCB_* 环境变量基线 + DB settings KV 运行时覆盖
|
||||||
│ └── schema.go # v2 新配置键 schema(键名/类型/默认值/边界)
|
│ └── schema.go # 26.9 新配置键 schema(键名/类型/默认值/边界)
|
||||||
├── model/ # GORM 模型 + 双方言 AutoMigrate
|
├── model/ # GORM 模型 + 双方言 AutoMigrate
|
||||||
├── cache/ # 缓存统一接口:redis.go / memory.go 双实现
|
├── cache/ # 缓存统一接口:redis.go / memory.go 双实现
|
||||||
├── database/ # 双方言连接与迁移(sqlite 默认 / postgres 可选)
|
├── database/ # 双方言连接与迁移(sqlite 默认 / postgres 可选)
|
||||||
@@ -43,7 +43,7 @@ server/
|
|||||||
- 双方言共用 GORM 抽象:AutoMigrate、settings KV、全部业务查询方言无关;
|
- 双方言共用 GORM 抽象:AutoMigrate、settings KV、全部业务查询方言无关;
|
||||||
唯一原生 DDL(migrates 台账表)在 `database.createMigratesTable` 内部分支处理。
|
唯一原生 DDL(migrates 台账表)在 `database.createMigratesTable` 内部分支处理。
|
||||||
|
|
||||||
## v2 新增配置键(internal/config/schema.go 为单一事实来源)
|
## 26.9 新增配置键(internal/config/schema.go 为单一事实来源)
|
||||||
|
|
||||||
| 键 | 类型 | 默认 | 说明 |
|
| 键 | 类型 | 默认 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -57,7 +57,7 @@ server/
|
|||||||
| `max_file_size` | int64 | `0` | 需求 ⑩:存储策略-单文件上限字节(0=回落 `uploadSize`,≤10GiB) |
|
| `max_file_size` | int64 | `0` | 需求 ⑩:存储策略-单文件上限字节(0=回落 `uploadSize`,≤10GiB) |
|
||||||
| `uploadSize` / `allowed_file_types` / `storageLimit` / `openUpload` | 既有 | - | 存储策略既有键(语义不变) |
|
| `uploadSize` / `allowed_file_types` / `storageLimit` / `openUpload` | 既有 | - | 存储策略既有键(语义不变) |
|
||||||
| `uploadCount` / `uploadMinute` | 既有 | `10` / `1` | 上传频率限制(对齐参考 `ip_limit["upload"]`) |
|
| `uploadCount` / `uploadMinute` | 既有 | `10` / `1` | 上传频率限制(对齐参考 `ip_limit["upload"]`) |
|
||||||
| `upload_rate` / `download_rate` | int64 | `0` / `0` | **v3.2 带宽限速**:上下行字节/秒(0=不限速,≤1 GiB/s)。管理端改后立即生效(每请求动态读 KV);上传侧 middleware 包裹 `Request.Body`,下载侧在 `serveFile` 包裹 `storage.ReadCloser`;S3 预签名直传(客户端→S3)服务端无法限速。算法为时间窗对齐 sleep(`middleware/bandwidth.go`) |
|
| `upload_rate` / `download_rate` | int64 | `0` / `0` | **26.9 带宽限速**:上下行字节/秒(0=不限速,≤1 GiB/s)。管理端改后立即生效(每请求动态读 KV);上传侧 middleware 包裹 `Request.Body`,下载侧在 `serveFile` 包裹 `storage.ReadCloser`;S3 预签名直传(客户端→S3)服务端无法限速。算法为时间窗对齐 sleep(`middleware/bandwidth.go`) |
|
||||||
|
|
||||||
管理端与文档(t2/t4)以 `config.KVSchema()`(`settings.KVSchema()` re-export)为元数据源;
|
管理端与文档(t2/t4)以 `config.KVSchema()`(`settings.KVSchema()` re-export)为元数据源;
|
||||||
schema 同步测试保证 `KVSchema()` 与 `defaults()` 逐键一致。
|
schema 同步测试保证 `KVSchema()` 与 `defaults()` 逐键一致。
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ import (
|
|||||||
"fileshare/internal/storage"
|
"fileshare/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APP_VERSION 版本号,对齐参考仓库 VERSION。v3.2 起改为 var 以便
|
// APP_VERSION 版本号,对齐参考仓库 VERSION。26.9 起改为 var 以便
|
||||||
// goreleaser 通过 -ldflags -X main.APP_VERSION=… 注入发布版本。
|
// goreleaser 通过 -ldflags -X main.APP_VERSION=… 注入发布版本。
|
||||||
//
|
//
|
||||||
// 编译时可选注入(goreleaser 触发),不注入则保持默认 26.9。
|
// 编译时可选注入(goreleaser 触发),不注入则保持默认 26.9。
|
||||||
var (
|
var (
|
||||||
APP_VERSION = "26.9"
|
APP_VERSION = "26.9"
|
||||||
BuildCommit = "dev"
|
BuildCommit = "dev"
|
||||||
BuildDate = "unknown"
|
BuildDate = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -105,7 +105,7 @@ func main() {
|
|||||||
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
middleware.LimitMeta: {Count: cfg.GetInt("errorCount"), Window: minutes(cfg.GetInt("errorMinute"))},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;v3 包装为可热切换 Manager)
|
// 7. 存储引擎:注入配置 → 构造 → 健康预检(需求 ④;26.9 包装为可热切换 Manager)
|
||||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||||
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
|
bootStore, err := storage.NewEngine(ctx, cfg.Engine())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,7 +117,7 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
|
log.Printf("[boot] 存储引擎 %s 健康检查通过", cfg.Engine())
|
||||||
}
|
}
|
||||||
// v3:Manager 包装——保存/读取委托当前引擎;管理端可热切换
|
// 26.9:Manager 包装——保存/读取委托当前引擎;管理端可热切换
|
||||||
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
|
//(构建闭包在每次切换前用最新 KV 刷新 EngineOptions,参数改动即时生效)
|
||||||
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
|
store := storage.NewManager(cfg.Engine(), bootStore, func(name string) (storage.Storage, error) {
|
||||||
storage.SetEngineOptions(buildEngineOptions(cfg))
|
storage.SetEngineOptions(buildEngineOptions(cfg))
|
||||||
@@ -149,8 +149,12 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 9. HTTP 服务
|
// 9. HTTP 服务
|
||||||
// M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑
|
// M5:后台清理循环(过期预留/超时会话/直传残留对象),启动后 10 分钟首跑。
|
||||||
janitor.Start(ctx, db, store, 10*time.Minute)
|
// 26.9:过期分享回收(recycle_enabled/recycle_interval/retention_days 动态读取)
|
||||||
|
janitor.Start(ctx, db, store, 10*time.Minute, &janitor.Recycler{
|
||||||
|
Enabled: cfg.RecycleEnabled,
|
||||||
|
RetentionDays: cfg.RetentionDays,
|
||||||
|
})
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.Env.Listen,
|
Addr: cfg.Env.Listen,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
|
|||||||
+1
-1
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
github.com/redis/go-redis/v9 v9.17.0
|
github.com/redis/go-redis/v9 v9.17.0
|
||||||
|
golang.org/x/crypto v0.56.0
|
||||||
gorm.io/driver/postgres v1.6.2
|
gorm.io/driver/postgres v1.6.2
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
@@ -66,7 +67,6 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.56.0 // indirect
|
|
||||||
golang.org/x/net v0.58.0 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"fileshare/internal/config"
|
"fileshare/internal/config"
|
||||||
|
"fileshare/internal/janitor"
|
||||||
"fileshare/internal/middleware"
|
"fileshare/internal/middleware"
|
||||||
"fileshare/internal/model"
|
"fileshare/internal/model"
|
||||||
"fileshare/internal/response"
|
"fileshare/internal/response"
|
||||||
@@ -78,9 +80,12 @@ func registerAdmin(r *gin.Engine, d *Deps) {
|
|||||||
authed.PATCH("/settings/password", d.adminChangePassword)
|
authed.PATCH("/settings/password", d.adminChangePassword)
|
||||||
authed.POST("/settings/password", d.adminChangePassword)
|
authed.POST("/settings/password", d.adminChangePassword)
|
||||||
|
|
||||||
// v3 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
|
// 26.9 存储引擎:运行时热切换(健康检查通过才生效,失败保持原引擎)
|
||||||
authed.POST("/storage/switch", d.adminStorageSwitch)
|
authed.POST("/storage/switch", d.adminStorageSwitch)
|
||||||
|
|
||||||
|
// 26.9 过期回收:手动触发一轮(定时循环之外的管理端入口)
|
||||||
|
authed.POST("/recycle/run", d.adminRecycleRun)
|
||||||
|
|
||||||
// 审计日志查询(需求 ③;logs 为 list 的别名)
|
// 审计日志查询(需求 ③;logs 为 list 的别名)
|
||||||
authed.GET("/audit/list", d.adminAuditList)
|
authed.GET("/audit/list", d.adminAuditList)
|
||||||
authed.GET("/audit/logs", d.adminAuditList)
|
authed.GET("/audit/logs", d.adminAuditList)
|
||||||
@@ -404,7 +409,7 @@ func buildAdminFileItem(fc *model.FileCodes, now time.Time) gin.H {
|
|||||||
"hasDownloadLimit": fc.ExpiredCount >= 0, "has_download_limit": fc.ExpiredCount >= 0,
|
"hasDownloadLimit": fc.ExpiredCount >= 0, "has_download_limit": fc.ExpiredCount >= 0,
|
||||||
"isPermanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "is_permanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0,
|
"isPermanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0, "is_permanent": fc.ExpiredAt == nil && fc.ExpiredCount < 0,
|
||||||
"remainingDownloads": remaining, "remaining_downloads": remaining,
|
"remainingDownloads": remaining, "remaining_downloads": remaining,
|
||||||
"engine": fc.Engine, // v3:归属引擎(管理端展示/排查用)
|
"engine": fc.Engine, // 26.9:归属引擎(管理端展示/排查用)
|
||||||
}
|
}
|
||||||
if fc.FileHash != nil {
|
if fc.FileHash != nil {
|
||||||
item["fileHash"] = *fc.FileHash
|
item["fileHash"] = *fc.FileHash
|
||||||
@@ -784,7 +789,7 @@ func (d *Deps) adminFilePreview(c *gin.Context) {
|
|||||||
// ============ 配置 ============
|
// ============ 配置 ============
|
||||||
|
|
||||||
// configKeys 管理端可见/可改的配置键(不含 jwt_secret;admin_token 屏蔽展示)。
|
// configKeys 管理端可见/可改的配置键(不含 jwt_secret;admin_token 屏蔽展示)。
|
||||||
// v2 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
|
// 26.9 新增键(需求 ①②③④⑩):背景图、页脚、通知开关、保存/存储策略、频率限制。
|
||||||
var configKeys = []string{
|
var configKeys = []string{
|
||||||
"site_name", "name", "description", "page_explain", "keywords",
|
"site_name", "name", "description", "page_explain", "keywords",
|
||||||
"notify_title", "notify_content", "notify_enabled", "logo_url", "favicon_url",
|
"notify_title", "notify_content", "notify_enabled", "logo_url", "favicon_url",
|
||||||
@@ -794,11 +799,14 @@ var configKeys = []string{
|
|||||||
"code_generate_type", "enableChunk",
|
"code_generate_type", "enableChunk",
|
||||||
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
|
"uploadMinute", "uploadCount", "errorMinute", "errorCount",
|
||||||
"loginCount", "loginMinute",
|
"loginCount", "loginMinute",
|
||||||
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // v3.1:站点对外域名
|
"opacity", "background", "showAdminAddr", "robotsText", "site_domain", // 26.9:站点对外域名
|
||||||
"upload_rate", "download_rate", // v3.2:上下行带宽字节/秒(0=不限速)
|
"upload_rate", "download_rate", // 26.9:上下行带宽字节/秒(0=不限速)
|
||||||
|
// 26.9 回收与下载安全
|
||||||
|
"recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled",
|
||||||
|
"hotlink_enabled", "hotlink_whitelist", "direct_download", "direct_link_expire",
|
||||||
"adminSessionExpire", "storage_path", "local_storage_path",
|
"adminSessionExpire", "storage_path", "local_storage_path",
|
||||||
"file_storage",
|
"file_storage",
|
||||||
// v3 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
|
// 26.9 存储引擎与引擎参数(热切换;凭据为敏感键,get 掩码/update 空跳过)
|
||||||
"storage_engine",
|
"storage_engine",
|
||||||
"local_storage_path",
|
"local_storage_path",
|
||||||
"webdav_url", "webdav_root_path", "webdav_username", "webdav_password",
|
"webdav_url", "webdav_root_path", "webdav_username", "webdav_password",
|
||||||
@@ -807,7 +815,7 @@ var configKeys = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// intConfigKeys 需按 schema 边界校验的整型键(adminConfigUpdate 归一化用)。
|
// intConfigKeys 需按 schema 边界校验的整型键(adminConfigUpdate 归一化用)。
|
||||||
// v1 既有键保留原语义;v2 新增键(max_file_size/max_save_count/notify_enabled)
|
// v1 既有键保留原语义;26.9 新增键(max_file_size/max_save_count/notify_enabled)
|
||||||
// 的边界来自 settings.KVSchema(单一事实来源在 config/schema.go)。
|
// 的边界来自 settings.KVSchema(单一事实来源在 config/schema.go)。
|
||||||
var intConfigKeys = []string{
|
var intConfigKeys = []string{
|
||||||
"openUpload", "enableChunk", "showAdminAddr", "storageLimit",
|
"openUpload", "enableChunk", "showAdminAddr", "storageLimit",
|
||||||
@@ -815,7 +823,9 @@ var intConfigKeys = []string{
|
|||||||
"loginCount", "loginMinute", "max_save_seconds", "uploadSize",
|
"loginCount", "loginMinute", "max_save_seconds", "uploadSize",
|
||||||
"adminSessionExpire",
|
"adminSessionExpire",
|
||||||
"max_save_count", "max_file_size", "notify_enabled",
|
"max_save_count", "max_file_size", "notify_enabled",
|
||||||
"upload_rate", "download_rate", // v3.2
|
"upload_rate", "download_rate", // 26.9
|
||||||
|
"recycle_enabled", "recycle_interval", "retention_days", "dedup_enabled",
|
||||||
|
"hotlink_enabled", "direct_download", "direct_link_expire", // 26.9(hotlink_whitelist 为字符串键)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
|
// validateConfigValue 按 settings.KVSchema 校验单个配置值:
|
||||||
@@ -905,7 +915,7 @@ func toStrSlice(v any) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// adminConfigGet 读取配置(对齐参考 get_config:admin_token 屏蔽、jwt_secret 不下发)。
|
// adminConfigGet 读取配置(对齐参考 get_config:admin_token 屏蔽、jwt_secret 不下发)。
|
||||||
// v3:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
|
// 26.9:引擎凭据类敏感键返回掩码占位(前端表单"留空=不修改");storage_engine 为当前热切换后的引擎。
|
||||||
func (d *Deps) adminConfigGet(c *gin.Context) {
|
func (d *Deps) adminConfigGet(c *gin.Context) {
|
||||||
cfg := d.Cfg
|
cfg := d.Cfg
|
||||||
out := gin.H{}
|
out := gin.H{}
|
||||||
@@ -925,7 +935,7 @@ func (d *Deps) adminConfigGet(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
// jwt_secret 永不下发
|
// jwt_secret 永不下发
|
||||||
delete(out, "jwt_secret")
|
delete(out, "jwt_secret")
|
||||||
// 引擎运行时状态(v3:热切换即时生效,无需重启)
|
// 引擎运行时状态(26.9:热切换即时生效,无需重启)
|
||||||
out["_engine_hint"] = gin.H{
|
out["_engine_hint"] = gin.H{
|
||||||
"storage_backend": d.Store.CurrentName(),
|
"storage_backend": d.Store.CurrentName(),
|
||||||
"engines": gin.H{"local": true, "s3": true, "webdav": true},
|
"engines": gin.H{"local": true, "s3": true, "webdav": true},
|
||||||
@@ -973,7 +983,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
|||||||
if !known {
|
if !known {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 类型归一 + schema 边界校验(v2:数值/字符串长度/列表键统一走 KVSchema)
|
// 类型归一 + schema 边界校验(26.9:数值/字符串长度/列表键统一走 KVSchema)
|
||||||
isInt := false
|
isInt := false
|
||||||
for _, key := range intConfigKeys {
|
for _, key := range intConfigKeys {
|
||||||
if key == k {
|
if key == k {
|
||||||
@@ -1067,7 +1077,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
|||||||
dbPatch["jwt_secret"] = settings.GenerateJWTSecret()
|
dbPatch["jwt_secret"] = settings.GenerateJWTSecret()
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3.1:site_domain 规范化(http(s)://host[:port];空=用当前地址)
|
// 26.9:site_domain 规范化(http(s)://host[:port];空=用当前地址)
|
||||||
if raw, ok := dbPatch["site_domain"]; ok {
|
if raw, ok := dbPatch["site_domain"]; ok {
|
||||||
sv, _ := raw.(string)
|
sv, _ := raw.(string)
|
||||||
normalized, err := normalizeSiteDomain(sv)
|
normalized, err := normalizeSiteDomain(sv)
|
||||||
@@ -1078,7 +1088,7 @@ func (d *Deps) adminConfigUpdate(c *gin.Context) {
|
|||||||
dbPatch["site_domain"] = normalized
|
dbPatch["site_domain"] = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3 引擎键处理:参数键与 storage_engine 分离。
|
// 26.9 引擎键处理:参数键与 storage_engine 分离。
|
||||||
// 1) storage_engine 只接受合法枚举;
|
// 1) storage_engine 只接受合法枚举;
|
||||||
// 2) 敏感凭据键空串/掩码=不修改(避免管理端表单回显把密钥抹掉);
|
// 2) 敏感凭据键空串/掩码=不修改(避免管理端表单回显把密钥抹掉);
|
||||||
// 3) 先持久化普通键+参数键 → Invalidate 对应引擎缓存 → 再尝试 Switch 新引擎;
|
// 3) 先持久化普通键+参数键 → Invalidate 对应引擎缓存 → 再尝试 Switch 新引擎;
|
||||||
@@ -1152,7 +1162,7 @@ func engineOfParamKeys(patch map[string]any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminStorageSwitch v3 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
|
// adminStorageSwitch 26.9 存储引擎热切换:{engine:"local"|"s3"|"webdav"}。
|
||||||
// 成功:持久化 storage_engine KV 并返回当前引擎;失败:503 且原引擎不变。
|
// 成功:持久化 storage_engine KV 并返回当前引擎;失败:503 且原引擎不变。
|
||||||
func (d *Deps) adminStorageSwitch(c *gin.Context) {
|
func (d *Deps) adminStorageSwitch(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -1300,11 +1310,15 @@ func (d *Deps) fileByID(c *gin.Context, id int64) (*model.FileCodes, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。
|
// deleteFileCode 删除分享记录与存储文件(文本分享无存储文件)。
|
||||||
|
// 26.9:SHA512 去重开启时同一对象可能被多条分享引用——删除前按
|
||||||
|
// ContentHash+Engine+UUIDFileName 引用计数,仍有其他引用则保留对象。
|
||||||
func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
|
func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
|
||||||
if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil {
|
if fc.Text == nil && fc.FilePath != nil && fc.UUIDFileName != nil {
|
||||||
// v3:删除走文件归属引擎(旧引擎里的文件也要能删掉)
|
// 26.9:删除走文件归属引擎(旧引擎里的文件也要能删掉)
|
||||||
if delStore, dErr := d.storeFor(fc.Engine); dErr == nil {
|
if delStore, dErr := d.storeFor(fc.Engine); dErr == nil {
|
||||||
if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
if d.referencedByOther(c.Request.Context(), fc) {
|
||||||
|
// 还有其他分享引用该对象:仅删记录
|
||||||
|
} else if err := delStore.DeleteFile(c.Request.Context(), fileSavePath(fc)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||||
return errInternal("存储文件删除失败: " + err.Error())
|
return errInternal("存储文件删除失败: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1316,6 +1330,31 @@ func (d *Deps) deleteFileCode(c *gin.Context, fc *model.FileCodes) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// referencedByOther 该分享的存储对象是否仍被其他分享引用(SHA512 去重)。
|
||||||
|
// 无 ContentHash(历史数据/去重未开启)时恒 false——按旧语义直接删对象。
|
||||||
|
func (d *Deps) referencedByOther(ctx context.Context, fc *model.FileCodes) bool {
|
||||||
|
if fc.ContentHash == nil || *fc.ContentHash == "" || fc.UUIDFileName == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var cnt int64
|
||||||
|
_ = d.DB.WithContext(ctx).Model(&model.FileCodes{}).
|
||||||
|
Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?",
|
||||||
|
*fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID).
|
||||||
|
Count(&cnt).Error
|
||||||
|
return cnt > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRecycleRun 手动触发一轮过期回收(26.9;定时循环之外的入口)。
|
||||||
|
// 返回 {removed: 本轮回收条数}。
|
||||||
|
func (d *Deps) adminRecycleRun(c *gin.Context) {
|
||||||
|
removed := janitor.RecycleExpired(c.Request.Context(), d.DB, d.Store, &janitor.Recycler{
|
||||||
|
Enabled: d.Cfg.RecycleEnabled,
|
||||||
|
RetentionDays: d.Cfg.RetentionDays,
|
||||||
|
})
|
||||||
|
auditRecordSuccess(c, d.AuditSvc)
|
||||||
|
response.OK(c, gin.H{"removed": removed})
|
||||||
|
}
|
||||||
|
|
||||||
// deleteMany 批量删除:返回 (已删除, 不存在, 失败)。
|
// deleteMany 批量删除:返回 (已删除, 不存在, 失败)。
|
||||||
func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) {
|
func (d *Deps) deleteMany(c *gin.Context, ids []int64) (deleted []int64, missing []int64, failed []gin.H) {
|
||||||
deleted, missing = []int64{}, []int64{}
|
deleted, missing = []int64{}, []int64{}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func (d *Deps) chunkInit(c *gin.Context) {
|
|||||||
// 服务端按分片数上限校验总大小(防分片声明绕过)
|
// 服务端按分片数上限校验总大小(防分片声明绕过)
|
||||||
totalChunks := (req.FileSize + chunkSize - 1) / chunkSize
|
totalChunks := (req.FileSize + chunkSize - 1) / chunkSize
|
||||||
maxPossible := totalChunks * chunkSize
|
maxPossible := totalChunks * chunkSize
|
||||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
// 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||||
if err := d.CurrentUploadPolicy().CheckSize(maxPossible); err != nil {
|
if err := d.CurrentUploadPolicy().CheckSize(maxPossible); err != nil {
|
||||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
||||||
@@ -165,7 +165,7 @@ func (d *Deps) chunkInit(c *gin.Context) {
|
|||||||
ChunkHash: req.FileHash,
|
ChunkHash: req.FileHash,
|
||||||
FileName: safeName,
|
FileName: safeName,
|
||||||
SavePath: savePath,
|
SavePath: savePath,
|
||||||
Engine: d.Store.CurrentName(), // v3:会话归属引擎(分片/合并全程走同一引擎)
|
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎(分片/合并全程走同一引擎)
|
||||||
}
|
}
|
||||||
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
|
if err := d.DB.WithContext(ctx).Create(&session).Error; err != nil {
|
||||||
releaseStorage(ctx, d.DB, resToken)
|
releaseStorage(ctx, d.DB, resToken)
|
||||||
@@ -389,7 +389,7 @@ func (d *Deps) saveOneChunk(c *gin.Context, ctx context.Context, session *model.
|
|||||||
ChunkSize: session.ChunkSize,
|
ChunkSize: session.ChunkSize,
|
||||||
FileName: session.FileName,
|
FileName: session.FileName,
|
||||||
SavePath: session.SavePath,
|
SavePath: session.SavePath,
|
||||||
Engine: session.Engine, // v3:继承会话引擎
|
Engine: session.Engine, // 26.9:继承会话引擎
|
||||||
}
|
}
|
||||||
if err := d.DB.WithContext(ctx).
|
if err := d.DB.WithContext(ctx).
|
||||||
Where("upload_id = ? AND chunk_index = ?", session.UploadID, idx).
|
Where("upload_id = ? AND chunk_index = ?", session.UploadID, idx).
|
||||||
@@ -444,7 +444,7 @@ func (d *Deps) chunkStatus(c *gin.Context) {
|
|||||||
type chunkCompleteRequest struct {
|
type chunkCompleteRequest struct {
|
||||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||||
Code string `json:"code" form:"code"` // v3.1:自定义提取码(4-8 位字母数字,空=随机)
|
Code string `json:"code" form:"code"` // 26.9:自定义提取码(4-8 位字母数字,空=随机)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chunkComplete 合并分片并创建分享(对齐参考 complete_upload)。
|
// chunkComplete 合并分片并创建分享(对齐参考 complete_upload)。
|
||||||
@@ -489,7 +489,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// v3.1:自定义提取码(合并前校验,失败快速返回)
|
// 26.9:自定义提取码(合并前校验,失败快速返回)
|
||||||
if err := validatePickupCode(req.Code); err != nil {
|
if err := validatePickupCode(req.Code); err != nil {
|
||||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||||
@@ -539,7 +539,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
return rec.ChunkHash, nil
|
return rec.ChunkHash, nil
|
||||||
}
|
}
|
||||||
// v3:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
|
// 26.9:合并走会话归属引擎(会话创建时的引擎,即使中途热切换也不受影响)
|
||||||
mergeStore, sErr := d.storeFor(session.Engine)
|
mergeStore, sErr := d.storeFor(session.Engine)
|
||||||
if sErr != nil {
|
if sErr != nil {
|
||||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, session.FileSize)
|
||||||
@@ -556,7 +556,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建分享记录(v3.1:支持自定义提取码)
|
// 创建分享记录(26.9:支持自定义提取码)
|
||||||
code, err := pickCustomCode(ctx, d.DB, d.Cfg, req.Code)
|
code, err := pickCustomCode(ctx, d.DB, d.Cfg, req.Code)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
fc := model.FileCodes{
|
fc := model.FileCodes{
|
||||||
@@ -568,7 +568,7 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
|||||||
ExpiredAt: exp.ExpiredAt,
|
ExpiredAt: exp.ExpiredAt,
|
||||||
ExpiredCount: exp.ExpiredCount,
|
ExpiredCount: exp.ExpiredCount,
|
||||||
UsedCount: exp.UsedCount,
|
UsedCount: exp.UsedCount,
|
||||||
Engine: session.Engine, // v3:归属引擎戳
|
Engine: session.Engine, // 26.9:归属引擎戳
|
||||||
}
|
}
|
||||||
// 拆分路径与文件名(对齐参考:path=dirname(save_path), uuid=basename)
|
// 拆分路径与文件名(对齐参考:path=dirname(save_path), uuid=basename)
|
||||||
dir, name := splitDirBase(session.SavePath)
|
dir, name := splitDirBase(session.SavePath)
|
||||||
@@ -578,7 +578,11 @@ func (d *Deps) chunkComplete(c *gin.Context) {
|
|||||||
fc.Prefix = trimExt(name)
|
fc.Prefix = trimExt(name)
|
||||||
fc.Suffix = ext
|
fc.Suffix = ext
|
||||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||||
err = mapCodeConflict(err) // v3.1
|
err = mapCodeConflict(err) // 26.9
|
||||||
|
if err == nil {
|
||||||
|
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||||
|
d.applyDedup(ctx, mergeStore, session.SavePath, &fc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// 成功:清理分片与记录(走归属引擎)
|
// 成功:清理分片与记录(走归属引擎)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// v3.1:自定义提取码与站点域名单元测试。
|
// 26.9:自定义提取码与站点域名单元测试。
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// postForm 以 urlencoded 表单调用 handler(v3.1 测试辅助)。
|
// postForm 以 urlencoded 表单调用 handler(26.9 测试辅助)。
|
||||||
func postForm(d *Deps, path string, fields map[string]string) *httptest.ResponseRecorder {
|
func postForm(d *Deps, path string, fields map[string]string) *httptest.ResponseRecorder {
|
||||||
form := url.Values{}
|
form := url.Values{}
|
||||||
for k, v := range fields {
|
for k, v := range fields {
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Package api — dedup.go SHA512 内容去重(26.9):
|
||||||
|
// 上传完成后计算对象 SHA512,命中已有分享(同哈希+同引擎)则复用其存储对象、
|
||||||
|
// 删除本次上传的副本——相同文件只存储一份。历史数据(无哈希)不受影响。
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"fileshare/internal/model"
|
||||||
|
"fileshare/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hashObject 流式计算存储对象 SHA512(hex);读取失败返回空串(去重按尽力而为降级)。
|
||||||
|
func hashObject(ctx context.Context, store storage.Storage, savePath string) string {
|
||||||
|
dl, err := store.Open(ctx, savePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer func() { _ = dl.Close() }()
|
||||||
|
h := sha512.New()
|
||||||
|
if _, err := io.Copy(h, dl); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDedup 上传落库后执行去重:
|
||||||
|
// 1. 计算刚保存对象的 SHA512;
|
||||||
|
// 2. 命中同哈希+同引擎的其他分享 → 复用其 FilePath/UUIDFileName,删除本次副本;
|
||||||
|
// 3. 未命中 → 只回填 ContentHash。
|
||||||
|
//
|
||||||
|
// 任何失败都不影响上传结果(记录保留、哈希留空 = 不参与去重)。
|
||||||
|
func (d *Deps) applyDedup(ctx context.Context, store storage.Storage, savedPath string, fc *model.FileCodes) {
|
||||||
|
if !d.Cfg.DedupEnabled() || fc == nil || fc.ID == 0 || fc.Text != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := hashObject(ctx, store, savedPath)
|
||||||
|
if hash == "" {
|
||||||
|
log.Printf("[dedup] 哈希计算失败 code=%s(跳过去重)", fc.Code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{"content_hash": hash}
|
||||||
|
|
||||||
|
var old model.FileCodes
|
||||||
|
err := d.DB.WithContext(ctx).
|
||||||
|
Where("content_hash = ? AND engine = ? AND id <> ? AND uuid_file_name IS NOT NULL",
|
||||||
|
hash, fc.Engine, fc.ID).
|
||||||
|
First(&old).Error
|
||||||
|
switch {
|
||||||
|
case err == nil && old.UUIDFileName != nil && old.FilePath != nil:
|
||||||
|
// 命中:复用旧对象,删除本次副本
|
||||||
|
updates["file_path"] = *old.FilePath
|
||||||
|
updates["uuid_file_name"] = *old.UUIDFileName
|
||||||
|
if err := store.DeleteFile(ctx, savedPath); err != nil {
|
||||||
|
log.Printf("[dedup] 删除重复副本失败 code=%s: %v", fc.Code, err)
|
||||||
|
}
|
||||||
|
log.Printf("[dedup] 命中同内容分享 code=%s 复用 %s", fc.Code, old.Code)
|
||||||
|
case err != nil && err != gorm.ErrRecordNotFound:
|
||||||
|
log.Printf("[dedup] 去重查询失败 code=%s: %v", fc.Code, err)
|
||||||
|
}
|
||||||
|
if err := d.DB.WithContext(ctx).Model(fc).Updates(updates).Error; err != nil {
|
||||||
|
log.Printf("[dedup] 回填哈希失败 code=%s: %v", fc.Code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fc.ContentHash = &hash
|
||||||
|
if fp, ok := updates["file_path"]; ok {
|
||||||
|
s := fp.(string)
|
||||||
|
fc.FilePath = &s
|
||||||
|
}
|
||||||
|
if un, ok := updates["uuid_file_name"]; ok {
|
||||||
|
s := un.(string)
|
||||||
|
fc.UUIDFileName = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,7 +125,7 @@ func generateCode(style string) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 自定义提取码(v3.1,防撞库)============
|
// ============ 自定义提取码(26.9,防撞库)============
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// pickupCodeMinLen 最小长度:L3 由 4 提升至 5(4 位码空间仅 168 万,
|
// pickupCodeMinLen 最小长度:L3 由 4 提升至 5(4 位码空间仅 168 万,
|
||||||
@@ -172,7 +172,7 @@ func pickCustomCode(ctx context.Context, db *gorm.DB, cfg *config.Config, custom
|
|||||||
return custom, nil
|
return custom, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapCodeConflict v3.1:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
|
// mapCodeConflict 26.9:分享记录创建失败时,若是自定义码唯一索引冲突(并发兜底,
|
||||||
// pickCustomCode 的预查重未覆盖竞态),转为友好 400;其余错误原样返回。
|
// pickCustomCode 的预查重未覆盖竞态),转为友好 400;其余错误原样返回。
|
||||||
func mapCodeConflict(err error) error {
|
func mapCodeConflict(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -188,9 +188,9 @@ func mapCodeConflict(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 站点对外域名(v3.1)============
|
// ============ 站点对外域名(26.9)============
|
||||||
|
|
||||||
// SiteDomain 站点对外域名规范化(v3.1):空串合法(分享链接用当前访问地址)。
|
// SiteDomain 站点对外域名规范化(26.9):空串合法(分享链接用当前访问地址)。
|
||||||
// 接受 http(s)://host[:port] 或裸 host[:port](自动补 http://,内网场景)。
|
// 接受 http(s)://host[:port] 或裸 host[:port](自动补 http://,内网场景)。
|
||||||
// 拒绝路径/查询/片段/用户信息/非 http(s) 协议/非法主机字符(防 javascript: 注入分享链接)。
|
// 拒绝路径/查询/片段/用户信息/非 http(s) 协议/非法主机字符(防 javascript: 注入分享链接)。
|
||||||
var siteDomainHostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
|
var siteDomainHostRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
|
||||||
@@ -275,7 +275,7 @@ type expireResult struct {
|
|||||||
|
|
||||||
// resolveExpire 校验 expire_style 白名单并计算过期信息。
|
// resolveExpire 校验 expire_style 白名单并计算过期信息。
|
||||||
// 对齐参考:max_save_seconds>0 时为最长保存上限(超限 403),否则默认 7 天上限;
|
// 对齐参考:max_save_seconds>0 时为最长保存上限(超限 403),否则默认 7 天上限;
|
||||||
// v2 需求 ④:style=count 时 expire_value 不得超出 max_save_count(0=不限制,超限 403)。
|
// 26.9 需求 ④:style=count 时 expire_value 不得超出 max_save_count(0=不限制,超限 403)。
|
||||||
func resolveExpire(cfg *config.Config, expireValue int, expireStyle string) (*expireResult, error) {
|
func resolveExpire(cfg *config.Config, expireValue int, expireStyle string) (*expireResult, error) {
|
||||||
allowed := cfg.ExpireStyle()
|
allowed := cfg.ExpireStyle()
|
||||||
okStyle := false
|
okStyle := false
|
||||||
@@ -358,7 +358,7 @@ func formatDurationCN(d time.Duration) string {
|
|||||||
|
|
||||||
// ============ 存储路径 / 容量预留 ============
|
// ============ 存储路径 / 容量预留 ============
|
||||||
|
|
||||||
// storeFor v3:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
|
// storeFor 26.9:按归属引擎取存储实例(空戳/未知名回落当前引擎,兼容历史数据)。
|
||||||
func (d *Deps) storeFor(engine string) (storage.Storage, error) {
|
func (d *Deps) storeFor(engine string) (storage.Storage, error) {
|
||||||
if engine == "" || !storage.ValidEngine(engine) {
|
if engine == "" || !storage.ValidEngine(engine) {
|
||||||
return d.Store, nil
|
return d.Store, nil
|
||||||
@@ -656,7 +656,7 @@ func readMultipartHeader(f multipart.File, n int64) []byte {
|
|||||||
// ============ 杂项 ============
|
// ============ 杂项 ============
|
||||||
|
|
||||||
// humanSize 把字节数转成人类可读描述(B/KB/MB/GB 自适应;
|
// humanSize 把字节数转成人类可读描述(B/KB/MB/GB 自适应;
|
||||||
// v2 需求④:max_file_size 支持子 MB 上限,固定 MB 格式会显示 0.00 MB)。
|
// 26.9 需求④:max_file_size 支持子 MB 上限,固定 MB 格式会显示 0.00 MB)。
|
||||||
func humanSize(n int64) string {
|
func humanSize(n int64) string {
|
||||||
const kb, mb, gb = int64(1024), int64(1024 * 1024), int64(1024 * 1024 * 1024)
|
const kb, mb, gb = int64(1024), int64(1024 * 1024), int64(1024 * 1024 * 1024)
|
||||||
switch {
|
switch {
|
||||||
@@ -728,7 +728,7 @@ func bindJSONOrForm(c *gin.Context, obj any) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// v3.1.1 兼容归一化:旧前端 bundle(fetch 字符串 body 默认 text/plain)发的
|
// 26.9 兼容归一化:旧前端 bundle(fetch 字符串 body 默认 text/plain)发的
|
||||||
// 是 text/plain + urlencoded 格式。此类请求改写 Content-Type 后走表单绑定,
|
// 是 text/plain + urlencoded 格式。此类请求改写 Content-Type 后走表单绑定,
|
||||||
// 否则 ShouldBind 对 text/plain 不解析,非空字段全部丢失。
|
// 否则 ShouldBind 对 text/plain 不解析,非空字段全部丢失。
|
||||||
base := ct
|
base := ct
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// policy.go — v2 上传策略统一读取与校验(需求 ④⑩)。
|
// policy.go — 26.9 上传策略统一读取与校验(需求 ④⑩)。
|
||||||
//
|
//
|
||||||
// 管理端在后台设置页修改策略(settings KV,t1 schema)后,上传链路
|
// 管理端在后台设置页修改策略(settings KV,t1 schema)后,上传链路
|
||||||
// (share/file、chunk、presign)每次请求实时读取当前策略并动态校验:
|
// (share/file、chunk、presign)每次请求实时读取当前策略并动态校验:
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func newPolicyTestDeps(t *testing.T) *Deps {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("storage.NewLocalStorage: %v", err)
|
t.Fatalf("storage.NewLocalStorage: %v", err)
|
||||||
}
|
}
|
||||||
// v3:包装为 Manager(build 直接返回 local 实例,测试无需真实多引擎)
|
// 26.9:包装为 Manager(build 直接返回 local 实例,测试无需真实多引擎)
|
||||||
storeMgr := storage.NewManager("local", store, func(string) (storage.Storage, error) {
|
storeMgr := storage.NewManager("local", store, func(string) (storage.Storage, error) {
|
||||||
return storage.NewLocalStorage(filepath.Join(dir, "storage"))
|
return storage.NewLocalStorage(filepath.Join(dir, "storage"))
|
||||||
})
|
})
|
||||||
@@ -141,13 +141,13 @@ func respBody(t *testing.T, w *httptest.ResponseRecorder) (code int, data map[st
|
|||||||
// pngMagic 最小合法 PNG 头(magic 校验可识别)。
|
// pngMagic 最小合法 PNG 头(magic 校验可识别)。
|
||||||
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||||
|
|
||||||
// ============ ① 公开 config:v2 展示与策略字段下发 ============
|
// ============ ① 公开 config:26.9 展示与策略字段下发 ============
|
||||||
|
|
||||||
// TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围,
|
// TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围,
|
||||||
// 且响应不包含任何敏感键(admin_token/jwt_secret)。
|
// 且响应不包含任何敏感键(admin_token/jwt_secret)。
|
||||||
func TestPublicConfigV2Fields(t *testing.T) {
|
func TestPublicConfigV2Fields(t *testing.T) {
|
||||||
d := newPolicyTestDeps(t)
|
d := newPolicyTestDeps(t)
|
||||||
// 管理端先设置 v2 展示字段
|
// 管理端先设置 26.9 展示字段
|
||||||
if w := patchConfig(d, map[string]any{
|
if w := patchConfig(d, map[string]any{
|
||||||
"background_url": "https://cdn.example.com/bg.png",
|
"background_url": "https://cdn.example.com/bg.png",
|
||||||
"footer_text": "自定义页脚内容",
|
"footer_text": "自定义页脚内容",
|
||||||
@@ -204,9 +204,9 @@ func TestPublicConfigV2Fields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ ② 管理端 get/update:v2 键全链路 + 类型范围校验 ============
|
// ============ ② 管理端 get/update:26.9 键全链路 + 类型范围校验 ============
|
||||||
|
|
||||||
// TestAdminConfigV2RoundTrip 验证 v2 新键 update → get → public 的往返,
|
// TestAdminConfigV2RoundTrip 验证 26.9 新键 update → get → public 的往返,
|
||||||
// 且 admin_token 屏蔽、jwt_secret 不下发。
|
// 且 admin_token 屏蔽、jwt_secret 不下发。
|
||||||
func TestAdminConfigV2RoundTrip(t *testing.T) {
|
func TestAdminConfigV2RoundTrip(t *testing.T) {
|
||||||
d := newPolicyTestDeps(t)
|
d := newPolicyTestDeps(t)
|
||||||
@@ -480,7 +480,7 @@ func TestPolicySnapshotMatchesConfig(t *testing.T) {
|
|||||||
// 编译期保证 fmt 被使用(测试辅助函数中错误路径占位)。
|
// 编译期保证 fmt 被使用(测试辅助函数中错误路径占位)。
|
||||||
var _ = fmt.Sprintf
|
var _ = fmt.Sprintf
|
||||||
|
|
||||||
// ============ v3 存储引擎热切换 ============
|
// ============ 26.9 存储引擎热切换 ============
|
||||||
|
|
||||||
// switchEngine 调用 POST /admin/storage/switch。
|
// switchEngine 调用 POST /admin/storage/switch。
|
||||||
func switchEngine(d *Deps, engine string) *httptest.ResponseRecorder {
|
func switchEngine(d *Deps, engine string) *httptest.ResponseRecorder {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ type presignInitRequest struct {
|
|||||||
FileSize int64 `json:"file_size" form:"file_size"`
|
FileSize int64 `json:"file_size" form:"file_size"`
|
||||||
ExpireValue int `json:"expire_value" form:"expire_value"`
|
ExpireValue int `json:"expire_value" form:"expire_value"`
|
||||||
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
ExpireStyle string `json:"expire_style" form:"expire_style"`
|
||||||
Code string `json:"code" form:"code"` // v3.1:自定义提取码(init 时校验,完成时落库)
|
Code string `json:"code" form:"code"` // 26.9:自定义提取码(init 时校验,完成时落库)
|
||||||
}
|
}
|
||||||
|
|
||||||
// presignInit 初始化预签名上传(对齐参考 presign_upload_init):
|
// presignInit 初始化预签名上传(对齐参考 presign_upload_init):
|
||||||
@@ -74,7 +74,7 @@ func (d *Deps) presignInit(c *gin.Context) {
|
|||||||
response.Fail(c, http.StatusBadRequest, "文件名非法")
|
response.Fail(c, http.StatusBadRequest, "文件名非法")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// v3.1:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
|
// 26.9:自定义提取码提前校验(init 时快速失败;完成请求须再次携带)
|
||||||
if err := validatePickupCode(req.Code); err != nil {
|
if err := validatePickupCode(req.Code); err != nil {
|
||||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
@@ -86,7 +86,7 @@ func (d *Deps) presignInit(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
// 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||||
if err := d.CurrentUploadPolicy().CheckSize(req.FileSize); err != nil {
|
if err := d.CurrentUploadPolicy().CheckSize(req.FileSize); err != nil {
|
||||||
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
auditUploadEntry(c, "", safeName, req.FileSize, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
auditRecordFailed(c, d.AuditSvc, "文件大小超过限制")
|
||||||
@@ -146,7 +146,7 @@ func (d *Deps) presignInit(c *gin.Context) {
|
|||||||
FileSize: req.FileSize,
|
FileSize: req.FileSize,
|
||||||
SavePath: savePath,
|
SavePath: savePath,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
Engine: d.Store.CurrentName(), // v3:会话归属引擎
|
Engine: d.Store.CurrentName(), // 26.9:会话归属引擎
|
||||||
ExpireValue: req.ExpireValue,
|
ExpireValue: req.ExpireValue,
|
||||||
ExpireStyle: req.ExpireStyle,
|
ExpireStyle: req.ExpireStyle,
|
||||||
ExpiresAt: time.Now().Add(presignSessionExpires * time.Second),
|
ExpiresAt: time.Now().Add(presignSessionExpires * time.Second),
|
||||||
@@ -195,7 +195,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
|
|||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// v3.1:自定义提取码随代理上传表单携带(init 时已预校验)
|
// 26.9:自定义提取码随代理上传表单携带(init 时已预校验)
|
||||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||||
@@ -234,7 +234,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil {
|
if err = validateFileMagic(d.Cfg, session.FileName, fh.Header.Get("Content-Type"), readMultipartHeader(f, 64)); err == nil {
|
||||||
// v3:落盘走会话归属引擎
|
// 26.9:落盘走会话归属引擎
|
||||||
var ps storage.Storage
|
var ps storage.Storage
|
||||||
ps, sErr := d.storeFor(session.Engine)
|
ps, sErr := d.storeFor(session.Engine)
|
||||||
if sErr != nil {
|
if sErr != nil {
|
||||||
@@ -259,7 +259,7 @@ func (d *Deps) presignProxy(c *gin.Context) {
|
|||||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||||
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
|
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
|
||||||
}
|
}
|
||||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||||
@@ -299,7 +299,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
|
// 26.9:直传文件存在性按会话归属引擎检查(直传可能落在旧引擎)
|
||||||
psCheck, sErr := d.storeFor(session.Engine)
|
psCheck, sErr := d.storeFor(session.Engine)
|
||||||
if sErr != nil {
|
if sErr != nil {
|
||||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||||
@@ -307,7 +307,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
|
|||||||
respondError(c, mapStorageError(sErr))
|
respondError(c, mapStorageError(sErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// v3.1:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
|
// 26.9:自定义提取码随确认请求携带(query 或 JSON/form body,均可选)
|
||||||
customCode := c.Query("code")
|
customCode := c.Query("code")
|
||||||
if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 {
|
if customCode == "" && c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||||
var fin struct {
|
var fin struct {
|
||||||
@@ -378,7 +378,7 @@ func (d *Deps) presignConfirm(c *gin.Context) {
|
|||||||
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
releaseStorage(ctx, d.DB, "presign:"+uploadID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||||
_ = ps.DeleteFile(ctx, session.SavePath) // v3:清理走归属引擎
|
_ = ps.DeleteFile(ctx, session.SavePath) // 26.9:清理走归属引擎
|
||||||
}
|
}
|
||||||
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
auditUploadEntry(c, uploadID, session.FileName, session.FileSize, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
auditRecordFailed(c, d.AuditSvc, "创建分享失败")
|
||||||
@@ -399,7 +399,7 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
// v3.1:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
|
// 26.9:完成请求的自定义提取码兜底校验(init 已验,防只发完成请求绕过)
|
||||||
if err := validatePickupCode(customCode); err != nil {
|
if err := validatePickupCode(customCode); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -420,10 +420,14 @@ func (d *Deps) createRecordFromSession(c *gin.Context, session *model.PresignUpl
|
|||||||
ExpiredAt: exp.ExpiredAt,
|
ExpiredAt: exp.ExpiredAt,
|
||||||
ExpiredCount: exp.ExpiredCount,
|
ExpiredCount: exp.ExpiredCount,
|
||||||
UsedCount: exp.UsedCount,
|
UsedCount: exp.UsedCount,
|
||||||
Engine: session.Engine, // v3:归属引擎戳
|
Engine: session.Engine, // 26.9:归属引擎戳
|
||||||
}
|
}
|
||||||
if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
if err := d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||||
return "", mapCodeConflict(err) // v3.1:并发占用自定义码 → 友好 400
|
return "", mapCodeConflict(err) // 26.9:并发占用自定义码 → 友好 400
|
||||||
|
}
|
||||||
|
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||||
|
if store, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||||
|
d.applyDedup(ctx, store, session.SavePath, &fc)
|
||||||
}
|
}
|
||||||
return code, nil
|
return code, nil
|
||||||
}
|
}
|
||||||
@@ -474,7 +478,7 @@ func (d *Deps) presignCancel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if session.Mode == "direct" {
|
if session.Mode == "direct" {
|
||||||
// v3:清理走会话归属引擎
|
// 26.9:清理走会话归属引擎
|
||||||
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
if ps, sErr := d.storeFor(session.Engine); sErr == nil {
|
||||||
if exists, eErr := ps.FileExists(ctx, session.SavePath); eErr == nil && exists {
|
if exists, eErr := ps.FileExists(ctx, session.SavePath); eErr == nil && exists {
|
||||||
_ = ps.DeleteFile(ctx, session.SavePath)
|
_ = ps.DeleteFile(ctx, session.SavePath)
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// recycle_dedup_test.go — 26.9 回收与下载安全测试:
|
||||||
|
// SHA512 去重(同内容单存储 + 引用计数删除)、过期回收(时间/次数/留存期)、
|
||||||
|
// 防盗链中间件、S3 直链 302 重定向。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fileshare/internal/janitor"
|
||||||
|
"fileshare/internal/middleware"
|
||||||
|
"fileshare/internal/model"
|
||||||
|
"fileshare/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ 辅助 ============
|
||||||
|
|
||||||
|
// uploadOK 上传文件并断言 200,返回取件码(复用 policy_test 的 uploadFile/respBody)。
|
||||||
|
func uploadOK(t *testing.T, d *Deps, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
w := uploadFile(d, name, content, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("上传失败: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
_, data := respBody(t, w)
|
||||||
|
code, _ := data["code"].(string)
|
||||||
|
if code == "" {
|
||||||
|
t.Fatalf("响应缺少 code: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileByID 按 code 查记录。
|
||||||
|
func fileByCode(t *testing.T, d *Deps, code string) model.FileCodes {
|
||||||
|
t.Helper()
|
||||||
|
var fc model.FileCodes
|
||||||
|
if err := d.DB.Where("code = ?", code).First(&fc).Error; err != nil {
|
||||||
|
t.Fatalf("查询分享 %s: %v", code, err)
|
||||||
|
}
|
||||||
|
return fc
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectExists 检查本地引擎对象是否存在。
|
||||||
|
func objectExists(t *testing.T, d *Deps, fc model.FileCodes) bool {
|
||||||
|
t.Helper()
|
||||||
|
store, err := d.storeFor(fc.Engine)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ok, err := store.FileExists(context.Background(), fc.SavePath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FileExists: %v", err)
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SHA512 去重 ============
|
||||||
|
|
||||||
|
// TestDedupSameContentSingleObject 同内容上传两次 → 单存储对象 + 记录互引 +
|
||||||
|
// 删除其一对象保留,删除最后一条对象才删除。
|
||||||
|
func TestDedupSameContentSingleObject(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
content := []byte("dedup-me-26.9-同一个内容")
|
||||||
|
|
||||||
|
code1 := uploadOK(t, d, "a.txt", content)
|
||||||
|
code2 := uploadOK(t, d, "b.txt", content)
|
||||||
|
if code1 == code2 {
|
||||||
|
t.Fatal("两次上传应生成不同取件码")
|
||||||
|
}
|
||||||
|
fc1, fc2 := fileByCode(t, d, code1), fileByCode(t, d, code2)
|
||||||
|
|
||||||
|
if fc1.ContentHash == nil || *fc1.ContentHash == "" {
|
||||||
|
t.Fatal("第一条记录未回填 content_hash")
|
||||||
|
}
|
||||||
|
if fc1.ContentHash == nil || fc2.ContentHash == nil || *fc1.ContentHash != *fc2.ContentHash {
|
||||||
|
t.Fatalf("两条记录哈希应一致: %v vs %v", fc1.ContentHash, fc2.ContentHash)
|
||||||
|
}
|
||||||
|
if fc1.UUIDFileName == nil || fc2.UUIDFileName == nil || *fc1.UUIDFileName != *fc2.UUIDFileName {
|
||||||
|
t.Fatalf("去重应复用同一 UUID 文件名: %v vs %v", fc1.UUIDFileName, fc2.UUIDFileName)
|
||||||
|
}
|
||||||
|
if fc1.SavePath() != fc2.SavePath() {
|
||||||
|
t.Fatal("去重应指向同一存储路径")
|
||||||
|
}
|
||||||
|
// 去重后对象应存在
|
||||||
|
if !objectExists(t, d, fc1) {
|
||||||
|
t.Fatal("去重后对象应存在")
|
||||||
|
}
|
||||||
|
// 删除其一:对象保留(另一条仍引用);删除第二条:对象随之删除
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodDelete, "/admin/file/delete", nil)
|
||||||
|
if err := d.deleteFileCode(c, &fc1); err != nil {
|
||||||
|
t.Fatalf("删除第一条: %v", err)
|
||||||
|
}
|
||||||
|
if !objectExists(t, d, fc2) {
|
||||||
|
t.Fatal("仍有引用时对象不应被删除")
|
||||||
|
}
|
||||||
|
if err := d.deleteFileCode(c, &fc2); err != nil {
|
||||||
|
t.Fatalf("删除第二条: %v", err)
|
||||||
|
}
|
||||||
|
store, _ := d.storeFor("local")
|
||||||
|
if ok, _ := store.FileExists(context.Background(), fc2.SavePath()); ok {
|
||||||
|
t.Fatal("最后一个引用删除后对象应被删除")
|
||||||
|
}
|
||||||
|
var cnt int64
|
||||||
|
d.DB.Model(&model.FileCodes{}).Count(&cnt)
|
||||||
|
if cnt != 0 {
|
||||||
|
t.Fatalf("记录应全部删除,剩余 %d", cnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDedupDisabled 不去重:两记录各自独立对象。
|
||||||
|
func TestDedupDisabled(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
setKV(t, d, "dedup_enabled", "0")
|
||||||
|
content := []byte("no-dedup-content")
|
||||||
|
c1 := uploadOK(t, d, "x.txt", content)
|
||||||
|
c2 := uploadOK(t, d, "y.txt", content)
|
||||||
|
fc1, fc2 := fileByCode(t, d, c1), fileByCode(t, d, c2)
|
||||||
|
if fc1.ContentHash != nil && *fc1.ContentHash != "" {
|
||||||
|
t.Fatal("去重关闭时不应回填 content_hash")
|
||||||
|
}
|
||||||
|
if fc1.SavePath() == fc2.SavePath() {
|
||||||
|
t.Fatal("去重关闭时不应共享路径")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setKV 直写 KV 并应用到内存 Config(对齐生产链路:UpdateKV 落库 + ApplyKV 生效)。
|
||||||
|
func setKV(t *testing.T, d *Deps, key, value string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := d.Mgr.UpdateKV(context.Background(), map[string]any{key: value}); err != nil {
|
||||||
|
t.Fatalf("setKV %s: %v", key, err)
|
||||||
|
}
|
||||||
|
d.Cfg.ApplyKV(map[string]any{key: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 过期回收 ============
|
||||||
|
|
||||||
|
// makeFileRecord 直插一条文件分享记录(可指定过期形态)。
|
||||||
|
func makeFileRecord(t *testing.T, d *Deps, code string, expiredAt *time.Time, expiredCount int, createdAt time.Time) model.FileCodes {
|
||||||
|
t.Helper()
|
||||||
|
name := "obj-" + code + ".bin"
|
||||||
|
dir := "share/data/test"
|
||||||
|
store, _ := d.storeFor("local")
|
||||||
|
if _, err := store.SaveFile(context.Background(), bytes.NewReader([]byte("recycle-body")), dir+"/"+name); err != nil {
|
||||||
|
t.Fatalf("写入测试对象: %v", err)
|
||||||
|
}
|
||||||
|
fc := model.FileCodes{
|
||||||
|
Code: code, Prefix: "obj-" + code, Suffix: ".bin",
|
||||||
|
UUIDFileName: &name, FilePath: &dir, Size: 12,
|
||||||
|
ExpiredAt: expiredAt, ExpiredCount: expiredCount,
|
||||||
|
Engine: "local",
|
||||||
|
}
|
||||||
|
if err := d.DB.Create(&fc).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 校正 created_at(GORM 自动填 now)
|
||||||
|
if err := d.DB.Model(&model.FileCodes{}).Where("id = ?", fc.ID).Update("created_at", createdAt).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fc.CreatedAt = createdAt
|
||||||
|
return fc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecycleExpiredTimeAndCount 时间过期与次数耗尽都被回收。
|
||||||
|
func TestRecycleExpiredTimeAndCount(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
past := time.Now().Add(-time.Hour)
|
||||||
|
r1 := makeFileRecord(t, d, "RECYA", &past, -1, time.Now().Add(-2*time.Hour)) // 时间过期
|
||||||
|
r2 := makeFileRecord(t, d, "RECYB", &past, 0, time.Now().Add(-2*time.Hour)) // 次数耗尽
|
||||||
|
r3 := makeFileRecord(t, d, "RECYC", nil, 5, time.Now().Add(-2*time.Hour)) // 存活(无过期时间且有余量)
|
||||||
|
|
||||||
|
removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{
|
||||||
|
Enabled: func() bool { return true },
|
||||||
|
RetentionDays: func() int64 { return 0 },
|
||||||
|
})
|
||||||
|
if removed != 2 {
|
||||||
|
t.Fatalf("应回收 2 条,实际 %d", removed)
|
||||||
|
}
|
||||||
|
for _, fc := range []model.FileCodes{r1, r2} {
|
||||||
|
var cnt int64
|
||||||
|
d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt)
|
||||||
|
if cnt != 0 {
|
||||||
|
t.Fatalf("%s 记录应被回收", fc.Code)
|
||||||
|
}
|
||||||
|
if objectExists(t, d, fc) {
|
||||||
|
t.Fatalf("%s 存储对象应被删除", fc.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cnt int64
|
||||||
|
d.DB.Model(&model.FileCodes{}).Where("code = ?", r3.Code).Count(&cnt)
|
||||||
|
if cnt != 1 {
|
||||||
|
t.Fatal("存活分享不应被回收")
|
||||||
|
}
|
||||||
|
if !objectExists(t, d, r3) {
|
||||||
|
t.Fatal("存活分享对象应保留")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecycleRetentionDays 留存期:创建超 retention_days 的分享被回收。
|
||||||
|
func TestRecycleRetentionDays(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
fresh := makeFileRecord(t, d, "RETEN1", nil, 5, time.Now()) // 新
|
||||||
|
stale := makeFileRecord(t, d, "RETEN2", nil, 5, time.Now().Add(-48*time.Hour)) // 超 1 天留存
|
||||||
|
removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{
|
||||||
|
Enabled: func() bool { return true },
|
||||||
|
RetentionDays: func() int64 { return 1 },
|
||||||
|
})
|
||||||
|
if removed != 1 {
|
||||||
|
t.Fatalf("应回收 1 条,实际 %d", removed)
|
||||||
|
}
|
||||||
|
var cnt int64
|
||||||
|
d.DB.Model(&model.FileCodes{}).Where("code = ?", stale.Code).Count(&cnt)
|
||||||
|
if cnt != 0 {
|
||||||
|
t.Fatal("超留存期分享应被回收")
|
||||||
|
}
|
||||||
|
if !objectExists(t, d, fresh) {
|
||||||
|
t.Fatal("未超留存期的分享对象应保留")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLazyRecycleOnPickup 取件次数耗尽后再取 → 惰性回收(记录与对象删除)。
|
||||||
|
func TestLazyRecycleOnPickup(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
past := time.Now().Add(time.Hour)
|
||||||
|
fc := makeFileRecord(t, d, "LAZYa", &past, 1, time.Now())
|
||||||
|
if !d.consumeUsage(invokeContext(t), &fc) {
|
||||||
|
// 第一次:count 1→0 成功
|
||||||
|
t.Fatal("首次取件应成功")
|
||||||
|
}
|
||||||
|
if d.consumeUsage(invokeContext(t), &fc) {
|
||||||
|
t.Fatal("次数耗尽后取件应失败")
|
||||||
|
}
|
||||||
|
// 惰性回收是异步的:同步触发一次等价清理验证语义
|
||||||
|
janitor.RecycleRecord(context.Background(), d.DB, d.Store, &fc, &janitor.Recycler{
|
||||||
|
Enabled: func() bool { return true },
|
||||||
|
RetentionDays: func() int64 { return 0 },
|
||||||
|
})
|
||||||
|
var cnt int64
|
||||||
|
d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt)
|
||||||
|
if cnt != 0 {
|
||||||
|
t.Fatal("惰性回收应删除记录")
|
||||||
|
}
|
||||||
|
if objectExists(t, d, fc) {
|
||||||
|
t.Fatal("惰性回收应删除对象")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// invokeContext 构造带请求的测试 context。
|
||||||
|
func invokeContext(t *testing.T) *gin.Context {
|
||||||
|
t.Helper()
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 防盗链 ============
|
||||||
|
|
||||||
|
// TestHotlinkMiddleware 防盗链中间件矩阵。
|
||||||
|
func TestHotlinkMiddleware(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
mw := hotlinkProbe(d)
|
||||||
|
req := func(referer, host string) int {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||||
|
if referer != "" {
|
||||||
|
r.Header.Set("Referer", referer)
|
||||||
|
}
|
||||||
|
r.Host = host
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = r
|
||||||
|
mw(c)
|
||||||
|
if !c.IsAborted() {
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
setKV(t, d, "hotlink_enabled", "0")
|
||||||
|
if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusOK {
|
||||||
|
t.Fatalf("开关关闭时应全放行,got %d", got)
|
||||||
|
}
|
||||||
|
setKV(t, d, "hotlink_enabled", "1")
|
||||||
|
setKV(t, d, "hotlink_whitelist", "")
|
||||||
|
if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusForbidden {
|
||||||
|
t.Fatalf("外站 Referer 应 403,got %d", got)
|
||||||
|
}
|
||||||
|
if got := req("https://mysite.com/page", "mysite.com"); got != http.StatusOK {
|
||||||
|
t.Fatalf("同源 Referer 应放行,got %d", got)
|
||||||
|
}
|
||||||
|
if got := req("", "mysite.com"); got != http.StatusOK {
|
||||||
|
t.Fatalf("空 Referer 应放行,got %d", got)
|
||||||
|
}
|
||||||
|
setKV(t, d, "hotlink_whitelist", "friend.org, *.cdn.net")
|
||||||
|
if got := req("https://friend.org/x", "mysite.com"); got != http.StatusOK {
|
||||||
|
t.Fatalf("白名单精确命中应放行,got %d", got)
|
||||||
|
}
|
||||||
|
if got := req("https://sub.cdn.net/x", "mysite.com"); got != http.StatusOK {
|
||||||
|
t.Fatalf("白名单通配命中应放行,got %d", got)
|
||||||
|
}
|
||||||
|
if got := req("https://other.net/x", "mysite.com"); got != http.StatusForbidden {
|
||||||
|
t.Fatalf("非白名单应 403,got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hotlinkProbe 直接调用中间件构造器。
|
||||||
|
func hotlinkProbe(d *Deps) gin.HandlerFunc {
|
||||||
|
return middleware.HotlinkMiddleware(d.Cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 直链下载 ============
|
||||||
|
|
||||||
|
// presignFake 包装本地引擎,仅覆盖 PresignGetURL 返回固定签名 URL。
|
||||||
|
type presignFake struct {
|
||||||
|
storage.Storage
|
||||||
|
gotExpires int64
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *presignFake) PresignGetURL(_ context.Context, _ string, expires int64) (string, error) {
|
||||||
|
p.gotExpires = expires
|
||||||
|
return p.url, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectDownloadRedirect 直链开启 + 引擎支持 → 302 到签名 URL,且
|
||||||
|
// 有效期不超过分享剩余时效;直链关闭 → 走代理 200。
|
||||||
|
func TestDirectDownloadRedirect(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
// 桩包装原 local 引擎:302 不落盘,代理回落时仍能读到真实对象
|
||||||
|
origLocal, err := d.storeFor("local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake := &presignFake{Storage: origLocal, url: "https://s3.example.com/signed?X-Amz-Signature=abc"}
|
||||||
|
swapLocal(t, d, fake)
|
||||||
|
setKV(t, d, "direct_download", "1")
|
||||||
|
setKV(t, d, "direct_link_expire", "900")
|
||||||
|
|
||||||
|
content := []byte("direct-link-body")
|
||||||
|
code := uploadOK(t, d, "d.txt", content)
|
||||||
|
|
||||||
|
// 时间型分享剩余 5 分钟 → 直链有效期应被钳到 300s
|
||||||
|
exp := time.Now().Add(5 * time.Minute)
|
||||||
|
if err := d.DB.Model(&model.FileCodes{}).Where("code = ?", code).
|
||||||
|
Updates(map[string]any{"expired_at": exp, "expired_count": -1}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fc := fileByCode(t, d, code) // 重新取(带上过期时间)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||||
|
d.serveFile(c, &fc)
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("应 302 直链,实际 %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if loc := w.Header().Get("Location"); loc != fake.url {
|
||||||
|
t.Fatalf("Location 应为签名 URL,实际 %q", loc)
|
||||||
|
}
|
||||||
|
if fake.gotExpires > 300 {
|
||||||
|
t.Fatalf("直链有效期应被分享剩余时效钳位(≤300),实际 %d", fake.gotExpires)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭直链 → 回落代理 200
|
||||||
|
setKV(t, d, "direct_download", "0")
|
||||||
|
fc2 := fileByCode(t, d, code)
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
c2, _ := gin.CreateTestContext(w2)
|
||||||
|
c2.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||||
|
d.serveFile(c2, &fc2)
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("直链关闭应走代理 200,实际 %d", w2.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w2.Body.String(), "direct-link-body") {
|
||||||
|
t.Fatal("代理响应应包含文件内容")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectDownloadLocalFallback 本地引擎不支持直链 → 自动回落代理 200。
|
||||||
|
func TestDirectDownloadLocalFallback(t *testing.T) {
|
||||||
|
d := newPolicyTestDeps(t)
|
||||||
|
setKV(t, d, "direct_download", "1")
|
||||||
|
code := uploadOK(t, d, "f.txt", []byte("local-fallback"))
|
||||||
|
fc := fileByCode(t, d, code)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil)
|
||||||
|
d.serveFile(c, &fc)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("本地引擎应回落代理 200,实际 %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 存储桩 ============
|
||||||
|
|
||||||
|
// swapLocal 用桩替换 local 引擎(重建 Manager,工厂恒返回桩)。
|
||||||
|
func swapLocal(t *testing.T, d *Deps, fake storage.Storage) {
|
||||||
|
t.Helper()
|
||||||
|
factory := func(string) (storage.Storage, error) { return fake, nil }
|
||||||
|
d.Store = storage.NewManager("local", fake, factory)
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ type Deps struct {
|
|||||||
Mgr *settings.Manager
|
Mgr *settings.Manager
|
||||||
AuditSvc *audit.Service
|
AuditSvc *audit.Service
|
||||||
Limiter *middleware.RateLimiter
|
Limiter *middleware.RateLimiter
|
||||||
Store *storage.Manager // v3:可热切换引擎管理器(实现 Storage 接口)
|
Store *storage.Manager // 26.9:可热切换引擎管理器(实现 Storage 接口)
|
||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,8 @@ func Register(r *gin.Engine, d *Deps) {
|
|||||||
share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost)
|
share.POST("/metadata", d.Limiter.RequireRateLimit(middleware.LimitMeta), d.shareMetadataPost)
|
||||||
share.GET("/select", d.shareSelect)
|
share.GET("/select", d.shareSelect)
|
||||||
share.POST("/select", d.shareSelectPost)
|
share.POST("/select", d.shareSelectPost)
|
||||||
share.GET("/download", d.shareDownload)
|
// 26.9:下载防盗链(动态开关,空 Referer 放行)
|
||||||
|
share.GET("/download", middleware.HotlinkMiddleware(d.Cfg), d.shareDownload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 分片上传 ——
|
// —— 分片上传 ——
|
||||||
@@ -104,7 +105,7 @@ func (d *Deps) robotsText(c *gin.Context) {
|
|||||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(d.Cfg.GetString("robotsText")))
|
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(d.Cfg.GetString("robotsText")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicConfig 公共配置(前端首页/上传页所需;v2 需求 ①②③④⑩ 扩展):
|
// publicConfig 公共配置(前端首页/上传页所需;26.9 需求 ①②③④⑩ 扩展):
|
||||||
// - 展示字段:站点信息、Logo/favicon、背景图、页脚文案/备案号、通知;
|
// - 展示字段:站点信息、Logo/favicon、背景图、页脚文案/备案号、通知;
|
||||||
// - 策略范围(上传页动态渲染):大小上限、类型白名单、过期方式、保存
|
// - 策略范围(上传页动态渲染):大小上限、类型白名单、过期方式、保存
|
||||||
// 时间/次数上限、上传频率(仅范围,不含内部实现键)。
|
// 时间/次数上限、上传频率(仅范围,不含内部实现键)。
|
||||||
@@ -132,10 +133,10 @@ func (d *Deps) publicConfig(c *gin.Context) {
|
|||||||
// 需求 ②:页脚自定义内容与备案号
|
// 需求 ②:页脚自定义内容与备案号
|
||||||
"footer_text": cfg.FooterText(),
|
"footer_text": cfg.FooterText(),
|
||||||
"footer_beian": cfg.FooterBeian(),
|
"footer_beian": cfg.FooterBeian(),
|
||||||
// v3:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
|
// 26.9:当前存储引擎名(仅名称,任何引擎参数/凭据不下发)
|
||||||
"storage_engine": d.Store.CurrentName(),
|
"storage_engine": d.Store.CurrentName(),
|
||||||
"site_domain": d.Cfg.SiteDomain(),
|
"site_domain": d.Cfg.SiteDomain(),
|
||||||
// v3.2:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
|
// 26.9:上下行带宽字节/秒(公开下发,0=不限速,方便管理端展示当前值)
|
||||||
"upload_rate": d.Cfg.UploadRate(),
|
"upload_rate": d.Cfg.UploadRate(),
|
||||||
"download_rate": d.Cfg.DownloadRate(),
|
"download_rate": d.Cfg.DownloadRate(),
|
||||||
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
|
// 需求 ③:系统通知(开关 + 内容,前台右上角悬浮窗)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"fileshare/internal/audit"
|
"fileshare/internal/audit"
|
||||||
|
"fileshare/internal/janitor"
|
||||||
"fileshare/internal/middleware"
|
"fileshare/internal/middleware"
|
||||||
"fileshare/internal/model"
|
"fileshare/internal/model"
|
||||||
"fileshare/internal/response"
|
"fileshare/internal/response"
|
||||||
@@ -61,9 +63,22 @@ func (d *Deps) consumeUsage(c *gin.Context, fc *model.FileCodes) bool {
|
|||||||
"expired_count": gorm.Expr("CASE WHEN expired_count > 0 THEN expired_count - 1 ELSE expired_count END"),
|
"expired_count": gorm.Expr("CASE WHEN expired_count > 0 THEN expired_count - 1 ELSE expired_count END"),
|
||||||
"used_count": gorm.Expr("used_count + 1"),
|
"used_count": gorm.Expr("used_count + 1"),
|
||||||
})
|
})
|
||||||
|
if res.Error == nil && res.RowsAffected == 0 {
|
||||||
|
// 26.9:取件时惰性回收——记录已过期/次数耗尽,后台异步删除记录与对象
|
||||||
|
// (定时回收循环之外的"更好检查方法":访问即发现即回收,不等下一轮扫描)
|
||||||
|
d.recycleAsync(fc)
|
||||||
|
}
|
||||||
return res.Error == nil && res.RowsAffected > 0
|
return res.Error == nil && res.RowsAffected > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recycleAsync 异步回收单条过期分享(不阻塞请求;记录不存在时为幂等空操作)。
|
||||||
|
func (d *Deps) recycleAsync(fc *model.FileCodes) {
|
||||||
|
go janitor.RecycleRecord(context.Background(), d.DB, d.Store, fc, &janitor.Recycler{
|
||||||
|
Enabled: d.Cfg.RecycleEnabled,
|
||||||
|
RetentionDays: d.Cfg.RetentionDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// fileSavePath 拼接分享记录的存储相对路径(file_path/uuid_file_name)。
|
// fileSavePath 拼接分享记录的存储相对路径(file_path/uuid_file_name)。
|
||||||
func fileSavePath(fc *model.FileCodes) string {
|
func fileSavePath(fc *model.FileCodes) string {
|
||||||
dir := ""
|
dir := ""
|
||||||
@@ -90,7 +105,7 @@ func (d *Deps) shareText(c *gin.Context) {
|
|||||||
if !requireUploadLimit(c, d.Limiter) {
|
if !requireUploadLimit(c, d.Limiter) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// v3.1 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
|
// 26.9 修复:JSON/表单/ultipart 统一绑定(form+json 双标签——此前仅 PostForm 时,
|
||||||
// JSON 提交会静默存成空文本并 200,取件页空白)。
|
// JSON 提交会静默存成空文本并 200,取件页空白)。
|
||||||
var body struct {
|
var body struct {
|
||||||
Text string `json:"text" form:"text"`
|
Text string `json:"text" form:"text"`
|
||||||
@@ -120,7 +135,7 @@ func (d *Deps) shareText(c *gin.Context) {
|
|||||||
if expireStyle == "" {
|
if expireStyle == "" {
|
||||||
expireStyle = "day"
|
expireStyle = "day"
|
||||||
}
|
}
|
||||||
// v3.1:自定义提取码格式校验(4-8 位字母数字,空=随机)
|
// 26.9:自定义提取码格式校验(4-8 位字母数字,空=随机)
|
||||||
if err := validatePickupCode(body.Code); err != nil {
|
if err := validatePickupCode(body.Code); err != nil {
|
||||||
respondError(c, err)
|
respondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -153,11 +168,11 @@ func (d *Deps) shareText(c *gin.Context) {
|
|||||||
ExpiredAt: exp.ExpiredAt,
|
ExpiredAt: exp.ExpiredAt,
|
||||||
ExpiredCount: exp.ExpiredCount,
|
ExpiredCount: exp.ExpiredCount,
|
||||||
UsedCount: exp.UsedCount,
|
UsedCount: exp.UsedCount,
|
||||||
Engine: d.Store.CurrentName(), // v3:归属引擎戳(文本也记录,保持一致性)
|
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(文本也记录,保持一致性)
|
||||||
}
|
}
|
||||||
err = d.DB.WithContext(ctx).Create(&fc).Error
|
err = d.DB.WithContext(ctx).Create(&fc).Error
|
||||||
}
|
}
|
||||||
err = mapCodeConflict(err) // v3.1:自定义码唯一索引冲突 → 友好 400
|
err = mapCodeConflict(err) // 26.9:自定义码唯一索引冲突 → 友好 400
|
||||||
releaseStorage(ctx, d.DB, token)
|
releaseStorage(ctx, d.DB, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auditRecordFailed(c, d.AuditSvc, "文本分享创建失败")
|
auditRecordFailed(c, d.AuditSvc, "文本分享创建失败")
|
||||||
@@ -188,7 +203,7 @@ func (d *Deps) shareFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
origName := fh.Filename
|
origName := fh.Filename
|
||||||
// v2 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
// 26.9 需求 ④⑩:动态策略校验(max_file_size,0=回落 uploadSize)
|
||||||
if err := d.CurrentUploadPolicy().CheckSize(fh.Size); err != nil {
|
if err := d.CurrentUploadPolicy().CheckSize(fh.Size); err != nil {
|
||||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "大小超过限制")
|
auditRecordFailed(c, d.AuditSvc, "大小超过限制")
|
||||||
@@ -205,7 +220,7 @@ func (d *Deps) shareFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3.1:自定义提取码(落盘前校验,失败快速返回不占容量预留)
|
// 26.9:自定义提取码(落盘前校验,失败快速返回不占容量预留)
|
||||||
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
if err := validatePickupCode(c.PostForm("code")); err != nil {
|
||||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
auditRecordFailed(c, d.AuditSvc, "提取码非法")
|
||||||
@@ -257,12 +272,15 @@ func (d *Deps) shareFile(c *gin.Context) {
|
|||||||
ExpiredAt: exp.ExpiredAt,
|
ExpiredAt: exp.ExpiredAt,
|
||||||
ExpiredCount: exp.ExpiredCount,
|
ExpiredCount: exp.ExpiredCount,
|
||||||
UsedCount: exp.UsedCount,
|
UsedCount: exp.UsedCount,
|
||||||
Engine: d.Store.CurrentName(), // v3:归属引擎戳(下载按此取回)
|
Engine: d.Store.CurrentName(), // 26.9:归属引擎戳(下载按此取回)
|
||||||
}
|
}
|
||||||
if err = d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
if err = d.DB.WithContext(ctx).Create(&fc).Error; err != nil {
|
||||||
err = mapCodeConflict(err) // v3.1
|
err = mapCodeConflict(err) // 26.9
|
||||||
// 记录创建失败:清理已落盘文件
|
// 记录创建失败:清理已落盘文件
|
||||||
_ = d.Store.DeleteFile(ctx, savePath)
|
_ = d.Store.DeleteFile(ctx, savePath)
|
||||||
|
} else {
|
||||||
|
// 26.9:SHA512 内容去重(命中则复用旧对象并删除本次副本)
|
||||||
|
d.applyDedup(ctx, d.Store, savePath, &fc)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 保存失败:尽力清理半写文件
|
// 保存失败:尽力清理半写文件
|
||||||
@@ -273,7 +291,7 @@ func (d *Deps) shareFile(c *gin.Context) {
|
|||||||
auditUploadEntry(c, "", origName, fh.Size, 0)
|
auditUploadEntry(c, "", origName, fh.Size, 0)
|
||||||
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
|
auditRecordFailed(c, d.AuditSvc, "文件保存失败")
|
||||||
if be, ok := err.(*apiError); ok && be.Status == http.StatusBadRequest {
|
if be, ok := err.(*apiError); ok && be.Status == http.StatusBadRequest {
|
||||||
respondError(c, err) // v3.1:提取码冲突等业务 400 原样透出
|
respondError(c, err) // 26.9:提取码冲突等业务 400 原样透出
|
||||||
} else {
|
} else {
|
||||||
respondError(c, mapStorageError(err))
|
respondError(c, mapStorageError(err))
|
||||||
}
|
}
|
||||||
@@ -544,7 +562,7 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
|||||||
savePath := fileSavePath(fc)
|
savePath := fileSavePath(fc)
|
||||||
name := fc.Prefix + fc.Suffix
|
name := fc.Prefix + fc.Suffix
|
||||||
|
|
||||||
// v3:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
|
// 26.9:按文件归属引擎取回(切换引擎后旧文件仍可下载);空戳=历史数据回落当前引擎
|
||||||
store, err := d.storeFor(fc.Engine)
|
store, err := d.storeFor(fc.Engine)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
auditUploadEntry(c, fc.Code, name, fc.Size, 0)
|
auditUploadEntry(c, fc.Code, name, fc.Size, 0)
|
||||||
@@ -553,6 +571,29 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 26.9:对象存储直链下载——引擎支持 + 直链开关开启时 302 到限时预签名 URL,
|
||||||
|
// 文件字节不再经过本服务器(带宽成本转嫁对象存储)。签名有效期取
|
||||||
|
// direct_link_expire 与分享剩余时效的较小值;直链不可用静默回落代理。
|
||||||
|
if d.Cfg.DirectDownload() {
|
||||||
|
expires := d.Cfg.DirectLinkExpire()
|
||||||
|
if fc.ExpiredAt != nil {
|
||||||
|
if remain := int64(time.Until(*fc.ExpiredAt).Seconds()); remain > 0 && remain < expires {
|
||||||
|
expires = remain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url, err := store.PresignGetURL(ctx, savePath, expires); err == nil && url != "" {
|
||||||
|
auditUploadEntry(c, fc.Code, name, fc.Size, fc.Size)
|
||||||
|
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||||
|
e.TransferredBytes = fc.Size
|
||||||
|
e.SizeBytes = fc.Size
|
||||||
|
})
|
||||||
|
auditRecordSuccess(c, d.AuditSvc)
|
||||||
|
c.Redirect(http.StatusFound, url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 直链不可用:继续走代理下载(不中断取件)
|
||||||
|
}
|
||||||
|
|
||||||
// 先 Stat 拿总大小(用于审计与 Range 后缀解析)
|
// 先 Stat 拿总大小(用于审计与 Range 后缀解析)
|
||||||
var total int64 = -1
|
var total int64 = -1
|
||||||
if meta, err := store.Stat(ctx, savePath); err == nil && meta != nil {
|
if meta, err := store.Stat(ctx, savePath); err == nil && meta != nil {
|
||||||
@@ -597,7 +638,7 @@ func (d *Deps) serveFile(c *gin.Context, fc *model.FileCodes) {
|
|||||||
}
|
}
|
||||||
auditUploadEntry(c, fc.Code, name, total, 0)
|
auditUploadEntry(c, fc.Code, name, total, 0)
|
||||||
c.Status(status)
|
c.Status(status)
|
||||||
// v3.2:下载带宽限速(storage.ReadCloser → 限速 reader → c.Writer)
|
// 26.9:下载带宽限速(storage.ReadCloser → 限速 reader → c.Writer)
|
||||||
dlReader := middleware.WrapReadCloser(dl, d.Cfg.DownloadRate())
|
dlReader := middleware.WrapReadCloser(dl, d.Cfg.DownloadRate())
|
||||||
n, _ := io.Copy(c.Writer, dlReader)
|
n, _ := io.Copy(c.Writer, dlReader)
|
||||||
middleware.AuditSet(c, func(e *audit.Entry) {
|
middleware.AuditSet(c, func(e *audit.Entry) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// DefaultLogoURL / DefaultFaviconURL 默认 Logo 与 favicon(需求 ⑤):
|
// DefaultLogoURL / DefaultFaviconURL 默认 Logo 与 favicon(需求 ⑤):
|
||||||
// v2 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png,
|
// 26.9 起默认改用前端打包的本地资源(web/src/assets/brand/logo.svg + favicon.png,
|
||||||
// 经 Vite 产出 /assets/logo-*.svg 与 /assets/favicon-*.png)。此处留空,
|
// 经 Vite 产出 /assets/logo-*.svg 与 /assets/favicon-*.png)。此处留空,
|
||||||
// GET /api/v1/config 下发空值时前端 displayLogoUrl/displayFaviconUrl 回落到本地打包资源;
|
// GET /api/v1/config 下发空值时前端 displayLogoUrl/displayFaviconUrl 回落到本地打包资源;
|
||||||
// 管理端仍可设置任意 URL 全站替换。
|
// 管理端仍可设置任意 URL 全站替换。
|
||||||
@@ -62,11 +62,20 @@ func defaults() map[string]any {
|
|||||||
"file_storage": "local",
|
"file_storage": "local",
|
||||||
"storage_path": "",
|
"storage_path": "",
|
||||||
"storageLimit": 0,
|
"storageLimit": 0,
|
||||||
// v3:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
|
// 26.9:存储引擎运行时可配(热切换);空=沿用 Env.StorageEngine 启动值
|
||||||
"storage_engine": "",
|
"storage_engine": "",
|
||||||
"site_domain": "",
|
"site_domain": "",
|
||||||
"upload_rate": "0",
|
"upload_rate": "0",
|
||||||
"download_rate": "0",
|
"download_rate": "0",
|
||||||
|
// 26.9 回收与下载安全
|
||||||
|
"recycle_enabled": 1,
|
||||||
|
"recycle_interval": 1800,
|
||||||
|
"retention_days": 0,
|
||||||
|
"dedup_enabled": 1,
|
||||||
|
"hotlink_enabled": 0,
|
||||||
|
"hotlink_whitelist": "",
|
||||||
|
"direct_download": 1,
|
||||||
|
"direct_link_expire": 900,
|
||||||
// 站点信息
|
// 站点信息
|
||||||
"name": "文件快传",
|
"name": "文件快传",
|
||||||
"site_name": "文件快传", // 新增:管理端可自定义
|
"site_name": "文件快传", // 新增:管理端可自定义
|
||||||
@@ -78,7 +87,7 @@ func defaults() map[string]any {
|
|||||||
// 需求 ⑤:默认 Logo 与 favicon(空 = 前端使用打包的本地资源)
|
// 需求 ⑤:默认 Logo 与 favicon(空 = 前端使用打包的本地资源)
|
||||||
"logo_url": DefaultLogoURL,
|
"logo_url": DefaultLogoURL,
|
||||||
"favicon_url": DefaultFaviconURL,
|
"favicon_url": DefaultFaviconURL,
|
||||||
// 需求 ①:背景图(v2 新增 background_url;background 为参考实现既有键,保留兼容)
|
// 需求 ①:背景图(26.9 新增 background_url;background 为参考实现既有键,保留兼容)
|
||||||
"background": "",
|
"background": "",
|
||||||
"background_url": "",
|
"background_url": "",
|
||||||
// 需求 ②:页脚自定义内容与备案号
|
// 需求 ②:页脚自定义内容与备案号
|
||||||
@@ -280,7 +289,7 @@ func (c *Config) GetBool(key string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetStringSlice 取字符串切片配置。
|
// GetStringSlice 取字符串切片配置。
|
||||||
// UploadRate 上传带宽字节/秒(v3.2,0=不限速)。
|
// UploadRate 上传带宽字节/秒(26.9,0=不限速)。
|
||||||
func (c *Config) UploadRate() int {
|
func (c *Config) UploadRate() int {
|
||||||
v := c.GetInt(KeyUploadRate)
|
v := c.GetInt(KeyUploadRate)
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
@@ -289,7 +298,7 @@ func (c *Config) UploadRate() int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadRate 下载带宽字节/秒(v3.2,0=不限速)。
|
// DownloadRate 下载带宽字节/秒(26.9,0=不限速)。
|
||||||
func (c *Config) DownloadRate() int {
|
func (c *Config) DownloadRate() int {
|
||||||
v := c.GetInt(KeyDownloadRate)
|
v := c.GetInt(KeyDownloadRate)
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
@@ -298,7 +307,71 @@ func (c *Config) DownloadRate() int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteDomain 站点对外域名(v3.1):空=分享链接用当前访问地址。
|
// —— 26.9 回收与下载安全 ——
|
||||||
|
|
||||||
|
// RecycleEnabled 过期自动回收开关。
|
||||||
|
func (c *Config) RecycleEnabled() bool { return c.GetInt(KeyRecycleEnabled) == 1 }
|
||||||
|
|
||||||
|
// RecycleInterval 回收扫描间隔(秒,钳位 60~86400)。
|
||||||
|
func (c *Config) RecycleInterval() int64 {
|
||||||
|
v := c.GetInt64(KeyRecycleInterval)
|
||||||
|
if v < RecycleIntervalMin {
|
||||||
|
return RecycleIntervalMin
|
||||||
|
}
|
||||||
|
if v > RecycleIntervalMax {
|
||||||
|
return RecycleIntervalMax
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionDays 全局最长存储时长(天,0=不限制)。
|
||||||
|
func (c *Config) RetentionDays() int64 {
|
||||||
|
v := c.GetInt64(KeyRetentionDays)
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// DedupEnabled SHA512 内容去重开关。
|
||||||
|
func (c *Config) DedupEnabled() bool { return c.GetInt(KeyDedupEnabled) == 1 }
|
||||||
|
|
||||||
|
// HotlinkEnabled 下载防盗链开关。
|
||||||
|
func (c *Config) HotlinkEnabled() bool { return c.GetInt(KeyHotlinkEnabled) == 1 }
|
||||||
|
|
||||||
|
// HotlinkWhitelist 防盗链 Referer 白名单(逗号分隔域名,返回小写去空白切片)。
|
||||||
|
func (c *Config) HotlinkWhitelist() []string {
|
||||||
|
raw := c.GetString(KeyHotlinkWhitelist)
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.ToLower(strings.TrimSpace(p))
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectDownload 对象存储直链下载开关。
|
||||||
|
func (c *Config) DirectDownload() bool { return c.GetInt(KeyDirectDownload) == 1 }
|
||||||
|
|
||||||
|
// DirectLinkExpire 直链有效期(秒,钳位 60~3600)。
|
||||||
|
func (c *Config) DirectLinkExpire() int64 {
|
||||||
|
v := c.GetInt64(KeyDirectLinkExpire)
|
||||||
|
if v < DirectLinkExpireMin {
|
||||||
|
return DirectLinkExpireMin
|
||||||
|
}
|
||||||
|
if v > DirectLinkExpireMax {
|
||||||
|
return DirectLinkExpireMax
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteDomain 站点对外域名(26.9):空=分享链接用当前访问地址。
|
||||||
func (c *Config) SiteDomain() string {
|
func (c *Config) SiteDomain() string {
|
||||||
return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/")
|
return strings.TrimRight(strings.TrimSpace(c.GetString("site_domain")), "/")
|
||||||
}
|
}
|
||||||
@@ -421,7 +494,7 @@ func (c *Config) AdminSessionExpireSeconds() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Engine 当前存储引擎。
|
// Engine 当前存储引擎。
|
||||||
// Engine 返回当前存储引擎名:KV storage_engine 优先(v3 运行时可改),
|
// Engine 返回当前存储引擎名:KV storage_engine 优先(26.9 运行时可改),
|
||||||
// 空(未设置/历史数据)回落启动值 Env.StorageEngine(env 校验过的 local|s3|webdav)。
|
// 空(未设置/历史数据)回落启动值 Env.StorageEngine(env 校验过的 local|s3|webdav)。
|
||||||
// 枚举校验内联(避免 config→storage 反向依赖)。
|
// 枚举校验内联(避免 config→storage 反向依赖)。
|
||||||
func (c *Config) Engine() string {
|
func (c *Config) Engine() string {
|
||||||
|
|||||||
@@ -1,40 +1,49 @@
|
|||||||
// Package config — schema.go 定义 v2 新增配置键(KV)schema:
|
// Package config — schema.go 定义 26.9 新增配置键(KV)schema:
|
||||||
// 键名常量、类型、默认值与取值边界。管理与 API 层(t2)按下表读写与校验,
|
// 键名常量、类型、默认值与取值边界。管理与 API 层(t2)按下表读写与校验,
|
||||||
// 文档(t4)按本表生成说明。键名除参考实现既有 camelCase 键外,
|
// 文档(t4)按本表生成说明。键名除参考实现既有 camelCase 键外,
|
||||||
// v2 新增键统一 snake_case。
|
// 26.9 新增键统一 snake_case。
|
||||||
package config
|
package config
|
||||||
|
|
||||||
// —— v2 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
|
// —— 26.9 新增/沿用键名常量(单一事实来源;settings 包会 re-export)——
|
||||||
// 命名规则:v2 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
|
// 命名规则:26.9 新增键 snake_case;与参考实现对齐的既有键保持原拼写。
|
||||||
const (
|
const (
|
||||||
// 需求 ①:背景图
|
// 需求 ①:背景图
|
||||||
KeyBackground = "background" // 参考实现既有键(v1 兼容保留)
|
KeyBackground = "background" // 参考实现既有键(v1 兼容保留)
|
||||||
KeyBackgroundURL = "background_url" // v2 新增:背景图 URL 或上传后的访问地址(空=默认主题)
|
KeyBackgroundURL = "background_url" // 26.9 新增:背景图 URL 或上传后的访问地址(空=默认主题)
|
||||||
// 需求 ②:页脚
|
// 需求 ②:页脚
|
||||||
KeyFooterText = "footer_text" // v2 新增:页脚自定义内容(纯文本或受控 HTML 片段)
|
KeyFooterText = "footer_text" // 26.9 新增:页脚自定义内容(纯文本或受控 HTML 片段)
|
||||||
KeyFooterBeian = "footer_beian" // v2 新增:备案号(如 京ICP备2024xxxxxx号-1)
|
KeyFooterBeian = "footer_beian" // 26.9 新增:备案号(如 京ICP备2024xxxxxx号-1)
|
||||||
// 需求 ③:系统通知
|
// 需求 ③:系统通知
|
||||||
KeyNotifyEnabled = "notify_enabled" // v2 新增:通知开关,1 开启 / 0 关闭
|
KeyNotifyEnabled = "notify_enabled" // 26.9 新增:通知开关,1 开启 / 0 关闭
|
||||||
KeyNotifyTitle = "notify_title" // 既有键:通知标题
|
KeyNotifyTitle = "notify_title" // 既有键:通知标题
|
||||||
KeyNotifyContent = "notify_content" // 既有键:通知内容(允许 <a> 等受控 HTML)
|
KeyNotifyContent = "notify_content" // 既有键:通知内容(允许 <a> 等受控 HTML)
|
||||||
// 需求 ④:保存策略(上传页动态读取并在范围内选择)
|
// 需求 ④:保存策略(上传页动态读取并在范围内选择)
|
||||||
KeyMaxSaveSeconds = "max_save_seconds" // 既有键:最长保存秒数,0=不限制(仅受默认 7 天兜底)
|
KeyMaxSaveSeconds = "max_save_seconds" // 既有键:最长保存秒数,0=不限制(仅受默认 7 天兜底)
|
||||||
KeyMaxSaveCount = "max_save_count" // v2 新增:单次分享最大可取(保存)次数上限,0=不限制
|
KeyMaxSaveCount = "max_save_count" // 26.9 新增:单次分享最大可取(保存)次数上限,0=不限制
|
||||||
KeyExpireStyle = "expireStyle" // 既有键:允许的过期方式白名单
|
KeyExpireStyle = "expireStyle" // 既有键:允许的过期方式白名单
|
||||||
// 需求 ④:上传频率限制(既有键,对齐参考 ip_limit["upload"])
|
// 需求 ④:上传频率限制(既有键,对齐参考 ip_limit["upload"])
|
||||||
KeyUploadCount = "uploadCount" // 窗口内允许上传次数
|
KeyUploadCount = "uploadCount" // 窗口内允许上传次数
|
||||||
KeyUploadMinute = "uploadMinute" // 频率窗口(分钟)
|
KeyUploadMinute = "uploadMinute" // 频率窗口(分钟)
|
||||||
// 需求 ④⑩:存储策略(最大文件大小/允许类型/总容量)
|
// 需求 ④⑩:存储策略(最大文件大小/允许类型/总容量)
|
||||||
KeyUploadSize = "uploadSize" // 既有键:单文件上限(字节),参考实现语义
|
KeyUploadSize = "uploadSize" // 既有键:单文件上限(字节),参考实现语义
|
||||||
KeyMaxFileSize = "max_file_size" // v2 新增:存储策略-单文件上限(字节),0=回落 uploadSize
|
KeyMaxFileSize = "max_file_size" // 26.9 新增:存储策略-单文件上限(字节),0=回落 uploadSize
|
||||||
KeyAllowedTypes = "allowed_file_types" // 既有键:允许类型白名单("*" 不限制)
|
KeyAllowedTypes = "allowed_file_types" // 既有键:允许类型白名单("*" 不限制)
|
||||||
KeyStorageLimit = "storageLimit" // 既有键:站点总容量(字节),0=不限制
|
KeyStorageLimit = "storageLimit" // 既有键:站点总容量(字节),0=不限制
|
||||||
KeyOpenUpload = "openUpload" // 既有键:游客上传开关
|
KeyOpenUpload = "openUpload" // 既有键:游客上传开关
|
||||||
// v3:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
|
// 26.9:存储引擎运行时可配(热切换;file_storage 为参考既有键保留兼容)
|
||||||
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
|
KeyStorageEngine = "storage_engine" // 当前存储引擎:local|s3|webdav
|
||||||
KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址)
|
KeySiteDomain = "site_domain" // 站点对外域名(空=分享链接用当前地址)
|
||||||
KeyUploadRate = "upload_rate" // 上传带宽字节/秒(0=不限速)
|
KeyUploadRate = "upload_rate" // 上传带宽字节/秒(0=不限速)
|
||||||
KeyDownloadRate = "download_rate" // 下载带宽字节/秒(0=不限速)
|
KeyDownloadRate = "download_rate" // 下载带宽字节/秒(0=不限速)
|
||||||
|
// —— 26.9 回收与下载安全 ——
|
||||||
|
KeyRecycleEnabled = "recycle_enabled" // 过期分享自动回收开关(1 开 / 0 关)
|
||||||
|
KeyRecycleInterval = "recycle_interval" // 回收扫描间隔(秒,60~86400)
|
||||||
|
KeyRetentionDays = "retention_days" // 全局最长存储时长(天,0=不限制)
|
||||||
|
KeyDedupEnabled = "dedup_enabled" // SHA512 内容去重开关
|
||||||
|
KeyHotlinkEnabled = "hotlink_enabled" // 下载防盗链开关
|
||||||
|
KeyHotlinkWhitelist = "hotlink_whitelist" // 防盗链 Referer 白名单(逗号分隔域名)
|
||||||
|
KeyDirectDownload = "direct_download" // 对象存储直链下载开关(仅 S3 引擎生效)
|
||||||
|
KeyDirectLinkExpire = "direct_link_expire" // 直链有效期(秒,60~3600)
|
||||||
)
|
)
|
||||||
|
|
||||||
// —— 取值边界(管理端保存与 API 校验用)——
|
// —— 取值边界(管理端保存与 API 校验用)——
|
||||||
@@ -54,6 +63,16 @@ const (
|
|||||||
// 通知标题/内容最大长度。
|
// 通知标题/内容最大长度。
|
||||||
NotifyTitleMaxLen = 128
|
NotifyTitleMaxLen = 128
|
||||||
NotifyContentMaxLen = 2000
|
NotifyContentMaxLen = 2000
|
||||||
|
// 回收扫描间隔边界(秒):最快 1 分钟一轮,最慢 1 天一轮。
|
||||||
|
RecycleIntervalMin = 60
|
||||||
|
RecycleIntervalMax = 86400
|
||||||
|
// 全局存储时长上限(天):0=不限制,最长 10 年。
|
||||||
|
RetentionDaysMax = 3650
|
||||||
|
// 防盗链白名单最大长度。
|
||||||
|
HotlinkWhitelistMaxLen = 2048
|
||||||
|
// 直链有效期边界(秒)。
|
||||||
|
DirectLinkExpireMin = 60
|
||||||
|
DirectLinkExpireMax = 3600
|
||||||
)
|
)
|
||||||
|
|
||||||
// KVSchemaEntry 配置键元数据:类型 / 默认值 / 说明,供管理端 UI 与文档生成。
|
// KVSchemaEntry 配置键元数据:类型 / 默认值 / 说明,供管理端 UI 与文档生成。
|
||||||
@@ -66,7 +85,7 @@ type KVSchemaEntry struct {
|
|||||||
Description string // 中文说明
|
Description string // 中文说明
|
||||||
}
|
}
|
||||||
|
|
||||||
// KVSchema v2 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
|
// KVSchema 26.9 全量配置键 schema 表(含既有策略键,供管理端/文档/AI 校验)。
|
||||||
// 注意:Default 与 config defaults() 逐一对应(schema_test 保证)。
|
// 注意:Default 与 config defaults() 逐一对应(schema_test 保证)。
|
||||||
func KVSchema() []KVSchemaEntry {
|
func KVSchema() []KVSchemaEntry {
|
||||||
return []KVSchemaEntry{
|
return []KVSchemaEntry{
|
||||||
@@ -92,10 +111,19 @@ func KVSchema() []KVSchemaEntry {
|
|||||||
{KeyAllowedTypes, "[]string", []string{"*"}, -1, -1, "允许上传类型白名单(\"*\" 不限制)"},
|
{KeyAllowedTypes, "[]string", []string{"*"}, -1, -1, "允许上传类型白名单(\"*\" 不限制)"},
|
||||||
{KeyStorageLimit, "int64", int64(0), 0, -1, "站点总容量(字节),0=不限制"},
|
{KeyStorageLimit, "int64", int64(0), 0, -1, "站点总容量(字节),0=不限制"},
|
||||||
{KeyOpenUpload, "int", 1, 0, 1, "游客上传开关:1 开 / 0 需管理员登录"},
|
{KeyOpenUpload, "int", 1, 0, 1, "游客上传开关:1 开 / 0 需管理员登录"},
|
||||||
// —— v3 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
|
// —— 26.9 存储引擎(热切换;引擎参数键沿用 defaults() 既有键,管理端经 config get/update 读写)——
|
||||||
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE)"},
|
{KeyStorageEngine, "string", "", 0, 16, "当前存储引擎:local|s3|webdav(热切换,健康检查通过才生效;空=回落启动值 FCB_STORAGE_ENGINE)"},
|
||||||
{KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"},
|
{KeySiteDomain, "string", "", 0, 256, "站点对外域名(http(s)://host[:port],不带路径;空=分享链接用当前访问地址)"},
|
||||||
{KeyUploadRate, "int64", "0", 0, 1073741824, "上传带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
{KeyUploadRate, "int64", "0", 0, 1073741824, "上传带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
||||||
{KeyDownloadRate, "int64", "0", 0, 1073741824, "下载带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
{KeyDownloadRate, "int64", "0", 0, 1073741824, "下载带宽字节/秒(0=不限速;范围 0~1 GiB/s)"},
|
||||||
|
// —— 26.9 回收与下载安全 ——
|
||||||
|
{KeyRecycleEnabled, "int", 1, 0, 1, "过期分享自动回收开关:1 定时清理过期记录与存储对象 / 0 关闭"},
|
||||||
|
{KeyRecycleInterval, "int64", int64(1800), RecycleIntervalMin, RecycleIntervalMax, "回收扫描间隔(秒;范围 60~86400,默认 30 分钟)"},
|
||||||
|
{KeyRetentionDays, "int64", int64(0), 0, RetentionDaysMax, "全局最长存储时长(天):上传超过该天数的分享将被回收,0=不限制"},
|
||||||
|
{KeyDedupEnabled, "int", 1, 0, 1, "SHA512 内容去重:相同文件仅存储一份(多分享引用同一对象)"},
|
||||||
|
{KeyHotlinkEnabled, "int", 0, 0, 1, "下载防盗链:校验 Referer 白名单(空 Referer 放行)"},
|
||||||
|
{KeyHotlinkWhitelist, "string", "", 0, HotlinkWhitelistMaxLen, "防盗链白名单:逗号分隔域名(如 a.com,b.org;空=仅本站域名)"},
|
||||||
|
{KeyDirectDownload, "int", 1, 0, 1, "对象存储直链下载:S3 引擎时 302 跳转到限时预签名 URL(不走服务器代理)"},
|
||||||
|
{KeyDirectLinkExpire, "int64", int64(900), DirectLinkExpireMin, DirectLinkExpireMax, "直链有效期(秒;范围 60~3600,默认 15 分钟;不超过分享剩余时效)"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func TestKVSchemaNoDuplicates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestV2NewKeysPresent v2 新增键必须在 schema 与 defaults 中同时存在。
|
// TestV2NewKeysPresent 26.9 新增键必须在 schema 与 defaults 中同时存在。
|
||||||
func TestV2NewKeysPresent(t *testing.T) {
|
func TestV2NewKeysPresent(t *testing.T) {
|
||||||
def := defaults()
|
def := defaults()
|
||||||
newKeys := []string{
|
newKeys := []string{
|
||||||
@@ -44,13 +44,13 @@ func TestV2NewKeysPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, k := range newKeys {
|
for _, k := range newKeys {
|
||||||
if _, ok := def[k]; !ok {
|
if _, ok := def[k]; !ok {
|
||||||
t.Fatalf("v2 新键 %q 缺少默认值", k)
|
t.Fatalf("26.9 新键 %q 缺少默认值", k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestV2AccessorDefaults v2 便捷访问器默认语义。
|
// TestKVAccessorDefaults 26.9 便捷访问器默认语义。
|
||||||
func TestV2AccessorDefaults(t *testing.T) {
|
func TestKVAccessorDefaults(t *testing.T) {
|
||||||
t.Setenv("FCB_DB_DRIVER", "sqlite")
|
t.Setenv("FCB_DB_DRIVER", "sqlite")
|
||||||
t.Setenv("FCB_DB_DSN", "")
|
t.Setenv("FCB_DB_DSN", "")
|
||||||
c, err := New()
|
c, err := New()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Package janitor 后台清理循环(安全审计 M5):
|
// Package janitor 后台清理循环(安全审计 M5 / 26.9 过期回收):
|
||||||
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)与过期预签名会话
|
// 回收过期容量预留、超时未完成的上传会话(含其分片对象)、过期预签名会话
|
||||||
// (direct 模式残留对象一并删除)。此前这些资源仅在同 token 复用/显式取消时
|
// (direct 模式残留对象一并删除),以及过期/超留存期的分享记录与存储对象。
|
||||||
// 释放,恶意 init 可长期占用容量预留或累积垃圾数据。
|
|
||||||
package janitor
|
package janitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,8 +22,22 @@ const chunkSessionMaxAge = 24 * time.Hour
|
|||||||
// presignGrace 过期预签名会话的宽限时长(到点即删,避免与在途 confirm 竞争)。
|
// presignGrace 过期预签名会话的宽限时长(到点即删,避免与在途 confirm 竞争)。
|
||||||
const presignGrace = time.Hour
|
const presignGrace = time.Hour
|
||||||
|
|
||||||
|
// 回收批次上限:单轮每类最多处理 200 条,避免大清理阻塞下一 tick。
|
||||||
|
const recycleBatch = 200
|
||||||
|
|
||||||
|
// Recycler 回收配置(26.9):由 API 层注入(管理端 KV 动态读取)。
|
||||||
|
type Recycler struct {
|
||||||
|
// Enabled 过期自动回收开关。
|
||||||
|
Enabled func() bool
|
||||||
|
// RetentionDays 全局最长存储时长(天,0=不限制)。
|
||||||
|
RetentionDays func() int64
|
||||||
|
// OnRecycled 回收成功后的回调(审计可选),参数:码、文件名、字节数。
|
||||||
|
OnRecycled func(code, name string, size int64)
|
||||||
|
}
|
||||||
|
|
||||||
// Start 启动周期清理循环;ctx 取消时退出。
|
// Start 启动周期清理循环;ctx 取消时退出。
|
||||||
func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration) {
|
// interval 为兜底默认间隔;recycler 非 nil 时按 RecycleInterval 动态取间隔。
|
||||||
|
func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval time.Duration, recycler *Recycler) {
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -34,12 +47,15 @@ func Start(ctx context.Context, db *gorm.DB, store *storage.Manager, interval ti
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
Run(ctx, db, store)
|
Run(ctx, db, store)
|
||||||
|
if recycler != nil && recycler.Enabled != nil && recycler.Enabled() {
|
||||||
|
RecycleExpired(ctx, db, store, recycler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run 执行一轮清理;单项失败仅记日志,不影响其他项。
|
// Run 执行一轮基础设施清理;单项失败仅记日志,不影响其他项。
|
||||||
func Run(ctx context.Context, db *gorm.DB, store *storage.Manager) {
|
func Run(ctx context.Context, db *gorm.DB, store *storage.Manager) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
cleanExpiredReservations(ctx, db, now)
|
cleanExpiredReservations(ctx, db, now)
|
||||||
@@ -122,3 +138,102 @@ func cleanExpiredPresignSessions(ctx context.Context, db *gorm.DB, store *storag
|
|||||||
log.Printf("[janitor] 已清理过期预签名会话 upload_id=%s mode=%s", s.UploadID, s.Mode)
|
log.Printf("[janitor] 已清理过期预签名会话 upload_id=%s mode=%s", s.UploadID, s.Mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 26.9:过期分享回收 ============
|
||||||
|
|
||||||
|
// RecycleExpired 回收过期/超存储时长的分享记录与存储对象:
|
||||||
|
// - 时间过期:expired_count<0 且 expired_at 已过;
|
||||||
|
// - 次数耗尽:expired_count>=0 且 <=0;
|
||||||
|
// - 超留存期:retentionDays>0 且 created_at 早于 now-retentionDays;
|
||||||
|
// - 内容去重开启时同一存储对象可能被多条分享引用,删除前做引用计数
|
||||||
|
// (按 ContentHash/Engine/UUIDFileName 统计),仅删除最后一个引用。
|
||||||
|
//
|
||||||
|
// 返回本轮回收的分享数。由 janitor 定时循环与管理端手动触发共用。
|
||||||
|
func RecycleExpired(ctx context.Context, db *gorm.DB, store *storage.Manager, r *Recycler) int {
|
||||||
|
now := time.Now()
|
||||||
|
q := db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||||
|
Where("(expired_count < 0 AND expired_at IS NOT NULL AND expired_at < ?)"+
|
||||||
|
" OR (expired_count >= 0 AND expired_count <= 0)", now)
|
||||||
|
if r.RetentionDays != nil && r.RetentionDays() > 0 {
|
||||||
|
cutoff := now.AddDate(0, 0, -int(r.RetentionDays()))
|
||||||
|
q = q.Or("created_at < ?", cutoff)
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
if err := q.Limit(recycleBatch).Pluck("id", &ids).Error; err != nil {
|
||||||
|
log.Printf("[recycle] 查询过期分享失败: %v", err)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
var fc model.FileCodes
|
||||||
|
if err := db.WithContext(ctx).First(&fc, id).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 复核:Expired 语义(避免查询窗口内被取件续期)
|
||||||
|
if !fc.Expired(now) {
|
||||||
|
if r.RetentionDays == nil || r.RetentionDays() <= 0 || fc.CreatedAt.After(now.AddDate(0, 0, -int(r.RetentionDays()))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recycleOne(ctx, db, store, &fc, r)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("[recycle] 本轮回收 %d 条过期分享", n)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecycleRecord 回收单条分享(取件惰性回收入口):删除记录与存储对象(带引用计数)。
|
||||||
|
// 记录不存在时为幂等空操作。
|
||||||
|
func RecycleRecord(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) {
|
||||||
|
// 存在性复核:可能已被定时循环/其他请求回收
|
||||||
|
var cur model.FileCodes
|
||||||
|
if err := db.WithContext(ctx).Where("id = ?", fc.ID).First(&cur).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recycleOne(ctx, db, store, &cur, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recycleOne 删除单条分享记录及其存储对象(带去重引用计数)。
|
||||||
|
func recycleOne(ctx context.Context, db *gorm.DB, store *storage.Manager, fc *model.FileCodes, r *Recycler) {
|
||||||
|
if fc.Text == nil && fc.UUIDFileName != nil {
|
||||||
|
engine, err := engineFor(store, fc.Engine)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[recycle] 引擎不可用 code=%s: %v", fc.Code, err)
|
||||||
|
// 引擎不可用也删记录,避免永久堆积;对象留给对账巡检
|
||||||
|
} else {
|
||||||
|
// 去重引用计数:同 ContentHash+Engine+UUIDFileName 的其他分享还在,则不删对象
|
||||||
|
if fc.ContentHash != nil && *fc.ContentHash != "" {
|
||||||
|
var cnt int64
|
||||||
|
_ = db.WithContext(ctx).Model(&model.FileCodes{}).
|
||||||
|
Where("content_hash = ? AND engine = ? AND uuid_file_name = ? AND id <> ?",
|
||||||
|
*fc.ContentHash, fc.Engine, *fc.UUIDFileName, fc.ID).
|
||||||
|
Count(&cnt).Error
|
||||||
|
if cnt == 0 && fc.SavePath() != "" {
|
||||||
|
delFile(ctx, engine, fc.SavePath(), fc.Code)
|
||||||
|
}
|
||||||
|
} else if fc.SavePath() != "" {
|
||||||
|
delFile(ctx, engine, fc.SavePath(), fc.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.WithContext(ctx).Delete(fc).Error; err != nil {
|
||||||
|
log.Printf("[recycle] 删除分享记录失败 code=%s: %v", fc.Code, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r != nil && r.OnRecycled != nil {
|
||||||
|
r.OnRecycled(fc.Code, fc.Prefix+fc.Suffix, fc.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delFile 删除存储对象,NotFound 视为成功(幂等)。
|
||||||
|
func delFile(ctx context.Context, engine storage.Storage, savePath, code string) {
|
||||||
|
if err := engine.DeleteFile(ctx, savePath); err != nil &&
|
||||||
|
!errors.Is(err, storage.ErrNotFound) && !errors.Is(err, storage.ErrInvalidPath) {
|
||||||
|
log.Printf("[recycle] 删除存储对象失败 code=%s path=%s: %v", code, savePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
// v3.2:上传/下载带宽限速(字节/秒,0=不限速)。
|
// 26.9:上传/下载带宽限速(字节/秒,0=不限速)。
|
||||||
//
|
//
|
||||||
// 设计要点:
|
// 设计要点:
|
||||||
// - 令牌桶(token bucket):每 Read 计算自上次起累计可消费字节,
|
// - 令牌桶(token bucket):每 Read 计算自上次起累计可消费字节,
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Package middleware — hotlink.go 下载防盗链(26.9):
|
||||||
|
// 校验 Referer 白名单。规则:
|
||||||
|
// - Referer 为空(直接访问/curl/浏览器地址栏):放行(不误伤正常取件);
|
||||||
|
// - Referer 与当前请求 Host 同源:放行;
|
||||||
|
// - Referer 主机命中管理端白名单(hotlink_whitelist,逗号分隔域名,支持 *.example.com 通配):放行;
|
||||||
|
// - 其余一律 403。
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fileshare/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HotlinkMiddleware 返回防盗链中间件;cfg 动态读取开关与白名单(管理端改后立即生效)。
|
||||||
|
func HotlinkMiddleware(cfg *config.Config) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !cfg.HotlinkEnabled() {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ref := c.GetHeader("Referer")
|
||||||
|
if ref == "" {
|
||||||
|
c.Next() // 空 Referer 放行
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := url.Parse(ref)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
c.Next() // 非法 Referer 视同空,放行(避免误伤)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.EqualFold(u.Host, c.Request.Host) {
|
||||||
|
c.Next() // 同源放行
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hostAllowed(u.Host, cfg.HotlinkWhitelist()) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(403, gin.H{"message": "防盗链:外部站点引用不允许访问该资源"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostAllowed 判断主机是否命中白名单(精确匹配或 *. 通配后缀匹配)。
|
||||||
|
// 白名单条目可带端口;通配写作 .example.com 或 *.example.com。
|
||||||
|
func hostAllowed(host string, whitelist []string) bool {
|
||||||
|
if len(whitelist) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host = strings.ToLower(host)
|
||||||
|
for _, w := range whitelist {
|
||||||
|
w = strings.ToLower(strings.TrimSpace(w))
|
||||||
|
w = strings.TrimPrefix(w, "*") // *.example.com → .example.com
|
||||||
|
if w == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if host == strings.TrimPrefix(w, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(host, w) && strings.HasPrefix(w, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -22,15 +23,33 @@ type FileCodes struct {
|
|||||||
ExpiredCount int `gorm:"default:0" json:"expired_count"` // 剩余可取次数;<0 表示按时间过期
|
ExpiredCount int `gorm:"default:0" json:"expired_count"` // 剩余可取次数;<0 表示按时间过期
|
||||||
UsedCount int `gorm:"default:0" json:"used_count"` // 已取次数
|
UsedCount int `gorm:"default:0" json:"used_count"` // 已取次数
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256
|
FileHash *string `gorm:"size:64" json:"file_hash"` // SHA256
|
||||||
|
ContentHash *string `gorm:"size:128;index" json:"content_hash"` // 26.9:SHA512(内容去重;同哈希分享复用同一存储对象)
|
||||||
IsChunked bool `gorm:"default:false" json:"is_chunked"`
|
IsChunked bool `gorm:"default:false" json:"is_chunked"`
|
||||||
UploadID *string `gorm:"size:36" json:"upload_id"` // 分片上传会话 ID
|
UploadID *string `gorm:"size:36" json:"upload_id"` // 分片上传会话 ID
|
||||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:local|s3|webdav;空=历史数据按当前引擎取)
|
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:local|s3|webdav;空=历史数据按当前引擎取)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名。
|
// TableName 表名。
|
||||||
func (FileCodes) TableName() string { return "file_codes" }
|
func (FileCodes) TableName() string { return "file_codes" }
|
||||||
|
|
||||||
|
// SavePath 存储侧相对路径(file_path/uuid_file_name 拼接;对齐 api 层 fileSavePath)。
|
||||||
|
// 26.9:上移到模型层,供 api 与 janitor 共用(去重引用计数与回收删除都需要)。
|
||||||
|
func (f *FileCodes) SavePath() string {
|
||||||
|
dir := ""
|
||||||
|
if f.FilePath != nil {
|
||||||
|
dir = strings.Trim(*f.FilePath, "/")
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if f.UUIDFileName != nil {
|
||||||
|
name = *f.UUIDFileName
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return dir + "/" + name
|
||||||
|
}
|
||||||
|
|
||||||
// Expired 判断是否已过期(对齐参考语义:expired_count<0 按时间,否则按次数)。
|
// Expired 判断是否已过期(对齐参考语义:expired_count<0 按时间,否则按次数)。
|
||||||
func (f *FileCodes) Expired(now time.Time) bool {
|
func (f *FileCodes) Expired(now time.Time) bool {
|
||||||
if f.ExpiredAt == nil {
|
if f.ExpiredAt == nil {
|
||||||
@@ -55,7 +74,7 @@ type UploadChunk struct {
|
|||||||
SavePath string `gorm:"size:512" json:"save_path"`
|
SavePath string `gorm:"size:512" json:"save_path"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Completed bool `gorm:"default:false" json:"completed"`
|
Completed bool `gorm:"default:false" json:"completed"`
|
||||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:分片与会话记录当时引擎,合并走同一引擎)
|
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:分片与会话记录当时引擎,合并走同一引擎)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名。
|
// TableName 表名。
|
||||||
@@ -84,7 +103,7 @@ type PresignUploadSession struct {
|
|||||||
ExpireStyle string `gorm:"size:20;default:day" json:"expire_style"`
|
ExpireStyle string `gorm:"size:20;default:day" json:"expire_style"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(v3:直传/代理完成走同一引擎取回)
|
Engine string `gorm:"size:16;default:''" json:"engine"` // 归属存储引擎(26.9:直传/代理完成走同一引擎取回)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 表名。
|
// TableName 表名。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package settings — schema.go:v2 配置键 schema 常量与元数据表。
|
// Package settings — schema.go:26.9 配置键 schema 常量与元数据表。
|
||||||
//
|
//
|
||||||
// 键名常量的单一事实来源在 internal/config/schema.go(defaults() 需引用);
|
// 键名常量的单一事实来源在 internal/config/schema.go(defaults() 需引用);
|
||||||
// 本文件 re-export 供 API/管理层使用,并提供「键名/类型/默认值」全量表,
|
// 本文件 re-export 供 API/管理层使用,并提供「键名/类型/默认值」全量表,
|
||||||
@@ -35,7 +35,7 @@ const (
|
|||||||
KeyAllowedTypes = config.KeyAllowedTypes
|
KeyAllowedTypes = config.KeyAllowedTypes
|
||||||
KeyStorageLimit = config.KeyStorageLimit
|
KeyStorageLimit = config.KeyStorageLimit
|
||||||
KeyOpenUpload = config.KeyOpenUpload
|
KeyOpenUpload = config.KeyOpenUpload
|
||||||
// v3 存储引擎
|
// 26.9 存储引擎
|
||||||
KeyStorageEngine = config.KeyStorageEngine
|
KeyStorageEngine = config.KeyStorageEngine
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 敏感键:不允许出现在管理端 config get 下发/前端可见集合中(双模式下一致生效)。
|
// 敏感键:不允许出现在管理端 config get 下发/前端可见集合中(双模式下一致生效)。
|
||||||
// v3:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
|
// 26.9:引擎凭据(webdav_password/s3_secret_access_key/aws_session_token)加入敏感集——
|
||||||
// 管理端 get 返回掩码占位,update 时空串/掩码=不修改;公开 config 永不下发。
|
// 管理端 get 返回掩码占位,update 时空串/掩码=不修改;公开 config 永不下发。
|
||||||
var SensitiveKeys = []string{
|
var SensitiveKeys = []string{
|
||||||
"admin_token", "jwt_secret",
|
"admin_token", "jwt_secret",
|
||||||
@@ -72,7 +72,7 @@ func IsSensitiveKey(key string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// KVSchema 返回 v2 全量配置键元数据(键名/类型/默认值/边界/说明)。
|
// KVSchema 返回 26.9 全量配置键元数据(键名/类型/默认值/边界/说明)。
|
||||||
// 默认值必须与 config defaults() 一致(schema 同步测试保证)。
|
// 默认值必须与 config defaults() 一致(schema 同步测试保证)。
|
||||||
func KVSchema() []config.KVSchemaEntry { return config.KVSchema() }
|
func KVSchema() []config.KVSchemaEntry { return config.KVSchema() }
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// Manager 存储引擎管理器:实现 Storage 全接口并支持运行时热切换。
|
// Manager 存储引擎管理器:实现 Storage 全接口并支持运行时热切换。
|
||||||
//
|
//
|
||||||
// v3 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
|
// 26.9 需求:管理后台可设置存储类型(local|s3|webdav)与各引擎参数,
|
||||||
// 保存后无需重启即生效。设计要点:
|
// 保存后无需重启即生效。设计要点:
|
||||||
// - 读写/保存类操作全部委托到"当前引擎"(原子指针,无锁热路径);
|
// - 读写/保存类操作全部委托到"当前引擎"(原子指针,无锁热路径);
|
||||||
// - Switch 先构建并健康检查新引擎,成功才替换指针,失败保持原引擎;
|
// - Switch 先构建并健康检查新引擎,成功才替换指针,失败保持原引擎;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func TestSwitchSuccessAndCurrentName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(v3 核心语义)。
|
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(26.9 核心语义)。
|
||||||
func TestSwitchFailureKeepsCurrent(t *testing.T) {
|
func TestSwitchFailureKeepsCurrent(t *testing.T) {
|
||||||
var s3Fail atomic.Bool
|
var s3Fail atomic.Bool
|
||||||
s3Fail.Store(true) // s3 不健康
|
s3Fail.Store(true) // s3 不健康
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-D7AAbqvI.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";import{u as A}from"./auth-DjiKbqje.js";import"./admin-uFxGdgNa.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-QLbKGtH7.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";import{u as A}from"./auth-Yfi2oa1E.js";import"./admin-BZX1cFNW.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-DSPQyv0Z.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";import{u as A}from"./auth-o4aHzrA6.js";import"./admin-IDpKsD_2.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as p,u as v,i as h,I as g,c as w,G as s,b as e,t as n,f as t,A as d,w as k,F as y,O as r,q as m,B as R,o as V}from"./index-BKnWAKao.js";import{_ as x}from"./SiteNav.vue_vue_type_script_setup_true_lang-D3yLF4uh.js";import{u as A}from"./auth-Cp2GGuZy.js";import"./admin-KnbIpHLF.js";const B={class:"admin-shell"},C={class:"admin-aside"},b={class:"aside-title"},L=["aria-label"],N={class:"admin-main"},q=p({__name:"AdminLayout",setup(S){const{t:a}=v(),u=R(),o=A(),l=h();g(async()=>{o.isAuthed&&!o.checked&&await o.verify()});async function _(){await o.logout(),l.success(a("admin.nav.loggedOut")),u.replace({name:"admin-login"})}return(F,c)=>{const i=r("RouterLink"),f=r("RouterView");return V(),w(y,null,[s(x),e("div",B,[e("aside",C,[e("div",b,n(t(a)("admin.nav.title")),1),e("nav",{class:"aside-menu","aria-label":t(a)("admin.nav.menu")},[s(i,{to:{name:"admin-files"}},{default:d(()=>[m("📁 "+n(t(a)("admin.nav.files")),1)]),_:1}),s(i,{to:{name:"admin-audit"}},{default:d(()=>[m("🛡 "+n(t(a)("admin.nav.audit")),1)]),_:1}),s(i,{to:{name:"admin-settings"}},{default:d(()=>[m("⚙️ "+n(t(a)("admin.nav.settings")),1)]),_:1}),c[0]||(c[0]=e("div",{class:"aside-sep"},null,-1)),e("a",{href:"#",onClick:k(_,["prevent"])},"🚪 "+n(t(a)("admin.nav.logout")),1)],8,L)]),e("div",N,[s(f)])])],64)}}});export{q as default};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.field-sub[data-v-d049fcf9]{display:block;font-size:13px;font-weight:600;color:var(--c-text-2);margin-bottom:6px}.result-head[data-v-8bebf3d9]{display:flex;align-items:center;gap:10px;margin-bottom:14px}.result-name[data-v-8bebf3d9]{font-weight:600;word-break:break-all}.link-row[data-v-8bebf3d9]{display:flex;gap:8px}.link-row .input[data-v-8bebf3d9]{flex:1}.result-meta[data-v-8bebf3d9]{display:flex;align-items:center;gap:6px;color:var(--c-text-2);font-size:13px;flex-wrap:wrap}.code-copy[data-v-8bebf3d9]{display:block;width:100%;cursor:pointer;appearance:none;-webkit-appearance:none;transition:filter .15s}.code-copy[data-v-8bebf3d9]:hover{filter:brightness(.96)}.code-copy[data-v-8bebf3d9]:active{filter:brightness(.92)}.dropzone.disabled[data-v-1b57fe5c]{opacity:.55;cursor:not-allowed}
|
|
||||||
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-DSPQyv0Z.js";import{P as A}from"./PageShell-Bv1Rpp4p.js";import{u as D}from"./auth-o4aHzrA6.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";import"./admin-IDpKsD_2.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-QLbKGtH7.js";import{P as A}from"./PageShell-D1DY7qw8.js";import{u as D}from"./auth-Yfi2oa1E.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";import"./admin-BZX1cFNW.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-D7AAbqvI.js";import{P as A}from"./PageShell-D3dyUalL.js";import{u as D}from"./auth-DjiKbqje.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";import"./admin-uFxGdgNa.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as w,u as b,a as y,z as x,A as k,b as e,f as t,t as a,w as V,C as B,D as C,c as m,g as _,q as N,j as r,N as S,B as q,o as d,H as L,_ as P}from"./index-BKnWAKao.js";import{P as A}from"./PageShell-CBo29Oot.js";import{u as D}from"./auth-Cp2GGuZy.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-D3yLF4uh.js";import"./admin-KnbIpHLF.js";const E={class:"card",style:{"max-width":"380px",margin:"8vh auto 0"}},I={class:"login-head"},M=["src"],R={class:"card-title"},T={class:"card-sub"},U={class:"field"},j={for:"admin-password"},z=["placeholder"],H={key:0,class:"hint",style:{color:"var(--c-danger)","margin-bottom":"12px"}},F=["disabled"],G={key:0,class:"spin","aria-hidden":"true"},J={class:"hint",style:{"margin-top":"16px"}},K=w({__name:"LoginView",setup(O){const{t:s}=b(),c=S(),g=q(),h=D(),u=y(),i=r(""),l=r(!1),n=r("");async function f(){if(!i.value){n.value=s("admin.login.required");return}l.value=!0,n.value="";try{await h.login(i.value);const o=typeof c.query.redirect=="string"?c.query.redirect:"/admin/files";g.replace(o)}catch(o){n.value=o instanceof L?o.code===401?s("admin.login.wrongPassword"):o.msg:s("admin.login.failed"),i.value=""}finally{l.value=!1}}return(o,p)=>(d(),x(A,null,{default:k(()=>[e("section",E,[e("div",I,[e("img",{src:t(u).displayLogoUrl,alt:"Logo",class:"login-logo"},null,8,M),e("h1",R,a(t(s)("admin.login.title")),1),e("p",T,a(t(s)("admin.login.subtitle",{name:t(u).displayName})),1)]),e("form",{onSubmit:V(f,["prevent"])},[e("div",U,[e("label",j,a(t(s)("admin.login.password")),1),B(e("input",{id:"admin-password","onUpdate:modelValue":p[0]||(p[0]=v=>i.value=v),class:"input",type:"password",placeholder:t(s)("admin.login.passwordPlaceholder"),autocomplete:"current-password",autofocus:""},null,8,z),[[C,i.value]])]),n.value?(d(),m("p",H,a(n.value),1)):_("",!0),e("button",{class:"btn btn-block",type:"submit",disabled:l.value},[l.value?(d(),m("span",G)):_("",!0),N(" "+a(t(s)("admin.login.submit")),1)],8,F)],32),e("p",J,a(t(s)("admin.login.hint")),1)])]),_:1}))}}),$=P(K,[["__scopeId","data-v-26e7f2a4"]]);export{$ as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-D7AAbqvI.js";import{P as m}from"./PageShell-D3dyUalL.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-QLbKGtH7.js";import{P as m}from"./PageShell-D1DY7qw8.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-DSPQyv0Z.js";import{P as m}from"./PageShell-Bv1Rpp4p.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as c,u as i,z as d,A as a,b as t,t as e,f as s,G as l,q as p,O as u,o as _}from"./index-BKnWAKao.js";import{P as m}from"./PageShell-CBo29Oot.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-D3yLF4uh.js";const f={class:"card empty",style:{"max-width":"480px",margin:"10vh auto 0"}},h={style:{"font-weight":"600",color:"var(--c-text)"}},x={class:"hint"},F=c({__name:"NotFoundView",setup(y){const{t:o}=i();return(g,n)=>{const r=u("RouterLink");return _(),d(m,null,{default:a(()=>[t("div",f,[n[0]||(n[0]=t("div",{class:"empty-icon"},"🧭",-1)),t("p",h,e(s(o)("notFound.title")),1),t("p",x,e(s(o)("notFound.desc")),1),l(r,{class:"btn",to:"/",style:{"margin-top":"10px"}},{default:a(()=>[p(e(s(o)("notFound.back")),1)]),_:1})])]),_:1})}}});export{F as default};
|
|
||||||
-267
File diff suppressed because one or more lines are too long
-267
File diff suppressed because one or more lines are too long
-267
File diff suppressed because one or more lines are too long
-267
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-DSPQyv0Z.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-BKnWAKao.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-D3yLF4uh.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-QLbKGtH7.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as h,u as g,a as k,c as n,G as i,b as t,Q as y,e as v,f as e,t as a,g as c,q as r,A as d,F as x,h as B,O as C,o as l,_ as b}from"./index-D7AAbqvI.js";import{_ as w}from"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";const N={class:"site-footer"},S={class:"footer-left"},F={key:0,class:"footer-text"},P={key:1,class:"footer-beian"},T={key:2},V=["aria-label"],$=h({__name:"PageShell",setup(D){const{t:s}=g(),o=k(),m=new Date().getFullYear(),u=B(()=>!!(o.footerText.trim()||o.footerBeian.trim()));return(p,_)=>{const f=C("RouterLink");return l(),n(x,null,[i(w),t("main",{class:v(["page",{"page-wide":p.$route.meta.wide}])},[y(p.$slots,"default",{},void 0,!0)],2),t("footer",N,[t("div",S,[e(o).footerText.trim()?(l(),n("span",F,a(e(o).footerText),1)):c("",!0),e(o).footerBeian.trim()?(l(),n("span",P,a(e(o).footerBeian),1)):c("",!0),u.value?c("",!0):(l(),n("span",T,a(e(s)("footer.copyright",{year:e(m),name:e(o).displayName})),1))]),_[0]||(_[0]=t("span",{class:"footer-powered"},[r(" Powered by "),t("a",{href:"https://skymirror.top",target:"_blank",rel:"noopener noreferrer"},"SKYMirror"),r(" 26.9 ")],-1)),t("nav",{"aria-label":e(s)("footer.linkNav")},[i(f,{to:"/docs"},{default:d(()=>[r(a(e(s)("footer.docs")),1)]),_:1}),i(f,{to:"/openapi"},{default:d(()=>[r(a(e(s)("footer.openapi")),1)]),_:1}),i(f,{to:"/admin/files"},{default:d(()=>[r(a(e(s)("footer.admin")),1)]),_:1})],8,V)])],64)}}}),R=b($,[["__scopeId","data-v-80685d6f"]]);export{R as P};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-DSPQyv0Z.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-BKnWAKao.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-D7AAbqvI.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as b,u as p,c as r,b as o,t as i,f as c,h,o as f}from"./index-QLbKGtH7.js";const v={class:"pager"},x={class:"pager-info"},k=["disabled"],M=["disabled"],y=b({__name:"Pager",props:{page:{},size:{},total:{}},emits:["change"],setup(t,{emit:m}){const a=t,d=m,{t:n}=p(),s=h(()=>Math.max(1,Math.ceil(a.total/a.size)));function g(l){const e=Math.min(Math.max(1,l),s.value);e!==a.page&&d("change",e,a.size)}return(l,e)=>(f(),r("div",v,[o("span",x,i(c(n)("common.pagerInfo",{total:t.total,page:t.page,pages:s.value})),1),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page<=1,onClick:e[0]||(e[0]=u=>g(t.page-1))},i(c(n)("common.previousPage")),9,k),o("button",{class:"btn btn-ghost btn-sm",type:"button",disabled:t.page>=s.value,onClick:e[1]||(e[1]=u=>g(t.page+1))},i(c(n)("common.nextPage")),9,M)]))}});export{y as _};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-DSPQyv0Z.js";import{P as Q}from"./PageShell-Bv1Rpp4p.js";import{g as X,p as Y,b as Z}from"./share-DQTp5ax3.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-DSt3Q6Wq.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-BKnWAKao.js";import{P as Q}from"./PageShell-CBo29Oot.js";import{g as X,p as Y,b as Z}from"./share-B-zR67vw.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-D3yLF4uh.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-D7AAbqvI.js";import{P as Q}from"./PageShell-D3dyUalL.js";import{g as X,p as Y,b as Z}from"./share-x2wQCCnt.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-B5OHbKDQ.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{d as N,u as U,i as q,I,J as b,z as L,A as R,H as C,h as T,j as p,b as t,c as u,q as z,t as s,f as n,w as E,C as H,D as $,F as y,s as _,g as w,K as j,L as J,n as K,M as B,k as W,N as G,B as O,o as l}from"./index-QLbKGtH7.js";import{P as Q}from"./PageShell-D1DY7qw8.js";import{g as X,p as Y,b as Z}from"./share-Y37-qxxb.js";import"./SiteNav.vue_vue_type_script_setup_true_lang-8XmHL8P7.js";const ee={class:"card",style:{"max-width":"640px",margin:"12px auto 0"}},te={key:0,class:"loading-block"},ae={class:"empty"},oe={style:{"font-weight":"600",color:"var(--c-text)"}},ne={class:"hint"},se=["placeholder"],ie={class:"btn",type:"submit"},le={style:{display:"flex","align-items":"center",gap:"10px","flex-wrap":"wrap","margin-bottom":"4px"}},ue={class:"badge"},re={style:{"font-size":"16px","word-break":"break-all"}},ce={key:0,class:"hint",style:{margin:"0"}},pe={class:"hint",style:{"margin-bottom":"18px"}},de={key:0,class:"loading-block"},me={class:"text-view"},ve={style:{display:"flex",gap:"10px","margin-top":"14px","flex-wrap":"wrap"}},ye={class:"file-summary"},ke={style:{"font-weight":"600","word-break":"break-all"}},fe={class:"hint",style:{margin:"0"}},ge={key:0,style:{margin:"16px 0 6px"}},he={class:"progress"},xe={class:"hint"},_e=["disabled"],Be=N({__name:"PickupView",setup(we){const{t:e}=U(),D=G(),F=O(),k=q(),d=T(()=>String(D.params.code??"").trim().split(/\s+/)[0]),r=p("loading"),m=p(""),o=p(null),v=p(""),f=p(!1),c=p(null);async function g(){if(!d.value){r.value="error",m.value=e("pickup.emptyCode");return}r.value="loading",m.value="",o.value=null,v.value="";try{const a=await X(d.value);if(o.value=a,r.value="ready",a.isText){f.value=!0;try{v.value=await Y(d.value)}finally{f.value=!1}}}catch(a){r.value="error",m.value=a instanceof C?a.code===404?e("pickup.notFound"):a.code===423?e("home.rateLimited"):a.code===428?e("home.notInitialized"):a.msg:e("pickup.failedDefault")}}I(g),b(d,()=>{g()}),b(()=>e("pickup.emptyCode"),()=>{r.value==="error"&&m.value&&g()});async function A(){if(o.value){c.value=0;try{const{blob:a,filename:i}=await Z(d.value,x=>c.value=x);B(a,i||o.value.name||"download"),k.success(e("pickup.downloaded"))}catch(a){k.error(a instanceof C?a.msg:e("pickup.downloadFailed"))}finally{c.value=null}}}async function M(){await W(v.value)?k.success(e("pickup.copied")):k.error(e("common.copyFailed"))}function P(){if(!o.value)return;const a=new Blob([v.value],{type:"text/plain;charset=utf-8"}),i=o.value.name?.includes(".")?o.value.name:`${o.value.name||"text"}.txt`;B(a,i)}const h=p("");function S(){const a=h.value.trim();a&&F.push({name:"pickup",params:{code:a}})}const V=T(()=>{const a=o.value;return a?a.remainingDownloads===null||a.remainingDownloads===void 0||a.remainingDownloads<0?e("pickup.remainingUnlimited"):e("pickup.remainingCount",{n:a.remainingDownloads}):""});return(a,i)=>(l(),L(Q,null,{default:R(()=>[t("section",ee,[r.value==="loading"?(l(),u("div",te,[i[1]||(i[1]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.querying",{code:d.value})),1)])):r.value==="error"?(l(),u(y,{key:1},[t("div",ae,[i[2]||(i[2]=t("div",{class:"empty-icon"},"📮",-1)),t("p",oe,s(m.value||n(e)("pickup.failed")),1),t("p",ne,s(n(e)("pickup.confirmHint")),1)]),t("form",{class:"quick-pickup",style:{"margin-top":"6px","max-width":"none"},onSubmit:E(S,["prevent"])},[H(t("input",{"onUpdate:modelValue":i[0]||(i[0]=x=>h.value=x),class:"input input-mono",placeholder:n(e)("pickup.retryPlaceholder"),maxlength:"32"},null,8,se),[[$,h.value]]),t("button",ie,s(n(e)("pickup.retryButton")),1)],32)],64)):o.value?(l(),u(y,{key:2},[t("div",le,[t("span",ue,s(o.value.isText?n(e)("common.text"):n(e)("common.file")),1),t("strong",re,s(o.value.name),1),o.value.isText?w("",!0):(l(),u("span",ce,s(n(_)(o.value.size)),1))]),t("p",pe,s(V.value)+" · "+s(n(e)("pickup.expireAt",{time:o.value.expiredAt?n(j)(o.value.expiredAt):n(e)("time.permanent")}))+" · "+s(n(J)(o.value.expiredAt)),1),o.value.isText?(l(),u(y,{key:0},[f.value?(l(),u("div",de,[i[3]||(i[3]=t("span",{class:"spin","aria-hidden":"true"},null,-1)),z(" "+s(n(e)("pickup.loadingText")),1)])):(l(),u(y,{key:1},[t("pre",me,s(v.value),1),t("div",ve,[t("button",{class:"btn",type:"button",onClick:M},s(n(e)("pickup.copyContent")),1),t("button",{class:"btn btn-ghost",type:"button",onClick:P},s(n(e)("pickup.downloadTxt")),1)])],64))],64)):(l(),u(y,{key:1},[t("div",ye,[i[4]||(i[4]=t("span",{style:{"font-size":"30px"},"aria-hidden":"true"},"📄",-1)),t("div",null,[t("div",ke,s(o.value.name),1),t("div",fe,s(n(e)("pickup.sizeUsed",{size:n(_)(o.value.size),n:o.value.usedCount})),1)])]),c.value!==null?(l(),u("div",ge,[t("div",he,[t("i",{style:K({width:`${c.value}%`})},null,4)]),t("p",xe,s(n(e)("pickup.downloading",{percent:c.value})),1)])):w("",!0),t("button",{class:"btn btn-block",type:"button",disabled:c.value!==null,onClick:A}," ⬇ "+s(n(e)("pickup.downloadFile",{size:n(_)(o.value.size)})),9,_e)],64))],64)):w("",!0)])]),_:1}))}});export{Be as default};
|
|
||||||
-129
File diff suppressed because one or more lines are too long
-129
File diff suppressed because one or more lines are too long
-129
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.settings-grid[data-v-11453c11]{display:grid;gap:16px}.save-row[data-v-11453c11]{display:flex;justify-content:flex-end}.notify-switch-row[data-v-11453c11]{display:flex;align-items:center;justify-content:space-between;gap:12px}.notify-switch-row label[data-v-11453c11]{margin-bottom:0}.unit-row[data-v-11453c11]{display:flex;gap:8px;align-items:center}.unit-row .input[data-v-11453c11]{flex:1}.unit-select[data-v-11453c11]{flex:0 0 110px!important}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
.settings-grid[data-v-03aa64c9]{display:grid;gap:16px}.save-row[data-v-03aa64c9]{display:flex;justify-content:flex-end}.notify-switch-row[data-v-03aa64c9]{display:flex;align-items:center;justify-content:space-between;gap:12px}.notify-switch-row label[data-v-03aa64c9]{margin-bottom:0}.unit-row[data-v-03aa64c9]{display:flex;gap:8px;align-items:center}.unit-row .input[data-v-03aa64c9]{flex:1}.unit-select[data-v-03aa64c9]{flex:0 0 110px!important}
|
|
||||||
-129
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-QLbKGtH7.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-D7AAbqvI.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-BKnWAKao.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{d as z,u as E,a as x,c as i,G as B,f as t,A as m,b as a,F as _,r as v,az as S,t as r,h as d,aA as p,aB as T,O as A,o,z as M,q as V,e as b,aC as w}from"./index-DSPQyv0Z.js";const D={class:"site-nav"},F=["src"],I={class:"brand-name"},O=["aria-label"],R={class:"nav-controls"},U=["aria-label"],q=["aria-checked","title","onClick"],G={class:"nav-control-icon","aria-hidden":"true"},H=["title"],K=z({__name:"SiteNav",setup($){const{t:s}=E(),c=x(),{mode:u,setMode:k}=w(),g=d(()=>[{to:"/",label:s("nav.home"),match:n=>n==="/"}]);function y(n){return n.match(location.pathname)}const C={light:"☀️",dark:"🌙",system:"💻"},f=d(()=>({light:s("theme.light"),dark:s("theme.dark"),system:s("theme.system")})),L=d(()=>p()==="zh-CN"?"中文":"English");function N(){const n=p()==="zh-CN"?"en-US":"zh-CN";T(n)}return(n,l)=>{const h=A("RouterLink");return o(),i("header",D,[B(h,{class:"brand",to:"/",title:t(s)("nav.homeTitle",{name:t(c).displayName})},{default:m(()=>[a("img",{src:t(c).displayLogoUrl,alt:"Logo",onError:l[0]||(l[0]=e=>e.target.style.visibility="hidden")},null,40,F),a("span",I,r(t(c).displayName),1)]),_:1},8,["title"]),a("nav",{class:"nav-links","aria-label":t(s)("nav.mainNav")},[(o(!0),i(_,null,v(g.value,e=>(o(),M(h,{key:e.to,to:e.to,class:b({"router-link-active":y(e)})},{default:m(()=>[V(r(e.label),1)]),_:2},1032,["to","class"]))),128))],8,O),a("div",R,[a("div",{class:"theme-seg",role:"radiogroup","aria-label":t(s)("theme.label")},[(o(!0),i(_,null,v(t(S),e=>(o(),i("button",{key:e,type:"button",role:"radio","aria-checked":t(u)===e,class:b({active:t(u)===e}),title:f.value[e],onClick:j=>t(k)(e)},[a("span",G,r(C[e]),1)],10,q))),128))],8,U),a("button",{class:"nav-control",type:"button",title:t(s)("lang.label"),onClick:N},[l[1]||(l[1]=a("span",{class:"nav-control-icon","aria-hidden":"true"},"🌐",-1)),a("span",null,r(L.value),1)],8,H)])])}}});export{K as _};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{v as n,S as e,x as s}from"./index-QLbKGtH7.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{v as n,S as e,x as s}from"./index-DSPQyv0Z.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{v as n,S as e,x as s}from"./index-BKnWAKao.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
import{v as n,S as e,x as s}from"./index-D7AAbqvI.js";function u(t){return n(s.adminLogin,{method:"POST",json:{password:t}})}function m(){return n(s.adminVerify)}async function l(){await n(s.adminLogout,{method:"POST"})}async function g(t){const a=await n(s.adminFileList,{query:{page:t.page,size:t.size,keyword:t.keyword||void 0}}),o=e(a,["data","list","items","files"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map(i=>({id:Number(e(i,["id"])??0),code:String(e(i,["code"])??""),name:String(e(i,["name"])??`${e(i,["prefix"])??""}${e(i,["suffix"])??""}`),suffix:String(e(i,["suffix"])??""),size:Number(e(i,["size"])??0),isText:!!(e(i,["isText","is_text"])??!1),expiredAt:e(i,["expiredAt","expired_at","expires_at"])??null,expiredCount:e(i,["expiredCount","expired_count"])??null,usedCount:Number(e(i,["usedCount","used_count"])??0),createdAt:e(i,["createdAt","created_at"])??null,isExpired:!!(e(i,["isExpired","is_expired"])??!1)}))}}async function p(t){await n(s.adminFileDelete,{method:"DELETE",json:{id:t}})}async function f(t){await n(s.adminFileBatchDelete,{method:"POST",json:{ids:t}})}async function y(t){await n(s.adminFileUpdate,{method:"PATCH",json:t})}async function w(){const t=await n(s.adminConfigGet);if(t&&typeof t=="object"&&!Array.isArray(t)){const a=t;return e(a,["config","data","settings"])??a}return{}}async function S(t){await n(s.adminConfigUpdate,{method:"PATCH",json:t})}async function _(t){const a=await n(s.adminStorageSwitch,{method:"POST",json:{engine:t}});return String(a?.engine??t)}async function x(t,a){await n(s.adminPasswordUpdate,{method:"PATCH",json:{old_password:t,new_password:a}})}async function b(t){const a=await n(s.adminAuditList,{query:{page:t.page,size:t.size,action:t.action||void 0,result:t.result||void 0,ip:t.ip||void 0,start_time:t.startTime||void 0,end_time:t.endTime||void 0}}),o=e(a,["data","list","items","logs"])??[],d=Number(e(a,["total","count"])??o.length);return{page:Number(e(a,["page"])??t.page),size:Number(e(a,["size"])??t.size),total:d,data:o.map((i,r)=>({id:Number(e(i,["id"])??r+1),action:String(e(i,["action"])??""),result:String(e(i,["result"])??""),fileCode:String(e(i,["file_code","fileCode","code"])??""),fileName:String(e(i,["file_name","fileName","name"])??""),sizeBytes:e(i,["size_bytes","sizeBytes","size"])??null,transferredBytes:e(i,["transferred_bytes","transferredBytes","bytes"])??null,ip:String(e(i,["ip","client_ip","clientIp"])??""),userAgent:String(e(i,["user_agent","userAgent"])??""),deviceOs:String(e(i,["device_os","deviceOs","os"])??""),deviceBrowser:String(e(i,["device_browser","deviceBrowser","browser"])??""),deviceType:String(e(i,["device_type","deviceType"])??""),actor:String(e(i,["actor"])??""),errorMsg:String(e(i,["error_msg","errorMsg","error"])??""),durationMs:e(i,["duration_ms","durationMs","duration"])??null,createdAt:e(i,["created_at","createdAt","time"])??null}))}}export{g as a,f as b,p as c,y as d,b as e,w as f,_ as g,S as h,x as i,l as j,m as k,u as l};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user