Glimmer — 一个轻量、自建、够用的图床。
浮光是一套自建图床:上传图片 → 自动压缩与格式转换 → 并行写入一个或多个存储后端 → 一键复制各种格式的链接 → 在图库中统一管理。
它不开放注册,账号由管理员创建,适合个人或 2~3 人的小团队部署在自己的 VPS 上。整套服务只依赖一个 SQLite 文件与本地磁盘,不需要 Redis、不需要消息队列、不需要任何额外中间件。
存储
- 三种后端,可并行写入:本地磁盘、S3 兼容对象存储(AWS S3 / MinIO / Cloudflare R2 / 阿里云 OSS / 腾讯云 COS)、标准 WebDAV(Nextcloud、坚果云等)。
- 一次上传可同时写多个后端,单个后端失败不会阻塞其他后端;每个后端独立记录
pending / ready / failed与失败原因,可单独重试。 - 重命名会逐后端执行「读回 → 写新路径 → 删除旧对象」,保证多后端内容一致;删除同理,未能清理的残留会以警告形式回传。
图片处理
- 基于
sharp的异步流水线:自动旋转 → 限制最大宽高(不放大)→ 剥离 EXIF → 按目标格式编码。 - 输出格式支持
webp / avif / jpeg / png / gif,一次可生成多种;多帧 GIF 在输出 GIF / WebP 时保留动画。 - 上传立即返回
202,处理在后台队列完成,前端实时显示每个文件的进度与结果。
上传体验
- 点击选择、拖拽(整页投放区)、
Ctrl/⌘ + V粘贴,支持多文件队列。 - 秒传去重:浏览器用 WebCrypto 先算原图 SHA-256 做预检,命中则直接复用已有结果,跳过整段文件传输。
- 四种链接一键复制:直链 / Markdown / HTML / BBCode。
- 可自定义命名模板(如
{date}/{random}-{origin}),支持按后端配置不同的路径前缀。
管理与安全
- 图库:网格 / 列表切换、搜索、按格式·后端·上传者·状态·日期筛选、排序、批量删除、详情抽屉。
- 关闭公开注册,账号由管理员创建;密码以 Argon2id 哈希存储,会话使用
httpOnly+SameSite=LaxCookie。 - 登录限流防爆破:按来源 IP 与按账号两个维度,只统计失败次数(正常登录不消耗额度);超限返回
429+Retry-After,且在密码校验之前就拒绝,避免攻击者借慢哈希打满 CPU。 - API Token:
Authorization: Bearer glm_…,给脚本与 CI 使用,可随时撤销;改密会自动级联撤销名下全部令牌。 - 访问统计:直链访问次数、出口流量、Top 热门图、按天趋势(详见 部署要点 中的统计口径说明)。
- 失败自动重试:按
30s → 2m → 8m → 32m指数退避,最多 4 次;排期写入数据库,重启进程不会丢任务。
界面
- 响应式:桌面左侧固定导航(图库 3~5 列)→ 平板折叠 → 手机底部 Tab 导航(2 列)。
- 浅色为主,可切换深色或跟随系统;中文字体与英文/数字字体可分别指定。
- 个人偏好跟着账户走:主题、中英文字体、上传页的「写入后端 / 输出格式 / 保留原图」都存在账号上。换设备、换浏览器、开无痕窗口登录,打开就是你上次的样子。
- 头像在个人页直接上传(自动裁成 1:1 方图,转 256×256 webp);直链由服务端按「用户 + 版本号」生成,换域名、改直链前缀都不会失效。
| 层级 | 技术 |
|---|---|
| 后端 | Hono + @hono/node-server + TypeScript |
| 前端 | Nuxt 3(SPA 模式)+ Vue 3 + TypeScript |
| UI | Tailwind CSS + shadcn 风格组件 + VueUse |
| 状态管理 | Pinia |
| 数据库 | SQLite + Drizzle ORM + better-sqlite3 |
| 图片处理 | sharp |
| 异步队列 | p-queue(进程内,无 Redis) |
| 认证 | Cookie 会话(httpOnly)+ Argon2id + Bearer 令牌 |
| 参数校验 | Zod |
| 部署 | Docker Compose(两个容器,可选 Nginx),或纯 Node 进程 |
包名:@glimmer/api / @glimmer/web / @glimmer/shared(pnpm workspace monorepo)。
这一节是写给「只想把它跑起来」的使用者的,每一步都可以直接复制执行,不需要 Docker 基础。 想二次开发请跳到 本地开发。
VPS、NAS、迷你主机、家里的旧电脑都行,1 核 1G 内存起步就够 —— 这个项目平时几乎不占资源, 只在处理图片时吃一点 CPU。
先确认是否已装 Docker:
docker version # 有 Client 和 Server 两段输出才算装好
docker compose version # 需要 v2.x,Docker 官方安装包自带两条命令任意一条报 command not found,就执行官方安装脚本:
curl -fsSL https://get.docker.com | shNAS 用户:群晖在「套件中心」装 Container Manager,威联通装 Container Station, Unraid / 极空间 / 绿联等一般已内置。它们都带 Docker Compose,下面的命令在 NAS 的「终端」或 SSH 里执行 (也可以把
docker-compose.yml内容粘贴进面板的「编排 / Compose」界面)。
只需要一个文件:docker-compose.yml。不需要创建 .env —— 所有配置都有可用默认值。
有 git:
git clone https://github.com/praming/Glimmer.git
cd Glimmer没有 git —— 直接下这一个文件就行,不必克隆整个仓库:
mkdir glimmer && cd glimmer
curl -fsSLO https://raw.githubusercontent.com/praming/Glimmer/main/docker-compose.yml只有想改默认值(端口、域名等)时才需要
.env,见 第 4 步。
docker compose up -d就这一条,不需要先做任何配置。看到 Started 就成功了。
首次启动会自动完成三件事:
| 事项 | 说明 |
|---|---|
| 拉取镜像 | 优先从 Docker Hub 拉官方镜像;拉不到(离线 / 镜像缺失)自动回退为本地构建 |
| 生成加密密钥 | 自动生成并保存到数据目录的 .secrets.json |
| 创建管理员 | 账号 admin、密码 change-me —— 登录后请立刻改掉 |
⚠️ .secrets.json用来解密你在后台填写的 S3 / WebDAV 凭据。它就在glimmer-data/里, 备份数据目录时请一并带上;删掉它,那些凭据就再也解不回来。 想自己管理密钥(例如多台机器共用一份数据),在.env里显式设置ENCRYPTION_KEY即可,它优先级更高。本编排把
.env声明为可选文件(env_file长语法),需要 Docker Compose ≥ 2.24 (docker compose version可查)。更老的版本会报解析错误,升级 compose 即可。
想强制用本地源码构建(例如自行改过代码),加上
--build:docker compose up -d --build。 首次构建会在容器内编译原生模块,视机器性能约 3–10 分钟(NAS 上更久),期间没有任何输出是正常的。
看看跑起来没有:
docker compose ps # glimmer-api 与 glimmer-web 都应是 running(api 显示 healthy 更好)
docker compose logs -f # 跟踪日志;按 Ctrl+C 退出,不会停服务访问 http://你的服务器IP:3001,用 admin / change-me 登录
(若在 .env 里把 WEB_PORT 改成了 80,则直接访问 http://你的服务器IP)。
如果你启动前就建过
.env并填了ADMIN_PASSWORD,请用你填的那个密码 ——change-me只在没配置过时才是默认值。两个都试过仍登不上,见 「常见问题 → 登录提示「用户名或密码不正确」,或忘记管理员密码了」, 那里有一条命令可以直接重置,不必删库。
登进去后建议顺手做三件事:
- 到**「个人资料」把密码改掉** ——
ADMIN_PASSWORD只在首次初始化时生效,之后改.env不会同步; - 到**「设置 → 存储后端」**确认默认的本地存储可用;需要接 S3 / WebDAV 也在这里配;
- 到**「设置 → 存储后端」把该后端的「访问域名」填成
https://你的域名/files(本地存储原样使用**这个值,路径要自己写全)—— 它决定复制出来的图片直链长什么样。想换掉/files这段前缀,见下文「对外地址」一节。
想改端口、域名这些,再建一个 .env(可以只写你要覆盖的那几行):
curl -fsSL https://raw.githubusercontent.com/praming/Glimmer/main/.env.example -o .env.example
cp .env.example .env改完执行 docker compose up -d 重启生效。最常改的几项:
| 变量 | 改成 | 不改会怎样 |
|---|---|---|
ADMIN_PASSWORD |
你自己的登录密码 | 默认 change-me,谁都能登进来 |
PUBLIC_BASE_URL |
http://你的服务器IP:3001 |
复制出去的图片直链别人打不开(也可在「存储后端 → 访问域名」里单独指定,优先级更高) |
COOKIE_SECURE |
用 http:// 访问就填 false |
密码明明对,却一直登录不上(登录 Cookie 被浏览器丢弃) |
WEB_PORT |
想直接 http://IP 访问就填 80 |
默认 3001,网址要带端口号 |
完整清单见 配置。
⚠️ ADMIN_PASSWORD只在users表为空的那一次启动里用于创建管理员。账号一旦创建, 密码哈希就已落库,之后再改.env不会被采纳(启动日志里会明确打印「已忽略 ADMIN_PASSWORD」)。 所以:「先up -d看了一眼,才想起去建.env」这种顺序,密码是不会生效的。日常改密码:登录后到**「个人资料」**;已经登不进去:用
docker exec glimmer-api node apps/api/dist/cli/reset-password.js重置(见「常见问题」)。
| 容器 | 作用 | 端口 |
|---|---|---|
glimmer-api |
后端:登录、上传、图片处理、SQLite 数据库 | 3000,仅容器内网,不对公网开放 |
glimmer-web |
前端页面;同时把 /api 与图片直链转发给后端 |
3001,唯一对外端口 |
关键在第二行:glimmer-web 自带同源转发
(实现见 apps/web/server/middleware/api-proxy.ts),
所以不需要额外装 Nginx —— 你只暴露一个端口,登录、上传、图片直链就全通了。
这是本项目与「一个应用 + 一个反代」常见组合最大的不同。
只有这三种情况才需要它:
- 想让服务监听到标准的 80 / 443 端口;
- 想用自己的域名 + HTTPS 证书;
- 想让图片由 Nginx 直接读磁盘返回,不走 Node 进程(有性能意义,但对小团队基本无感)。
docker compose --profile nginx up -dglimmer-nginx 带 profile 标记,所以:
- 不加
--profile nginx时它既不会启动、也不会被拉取,等于不存在; - 加了才启动,且一条命令随时可加可去:
docker compose --profile nginx up -d # 加上 Nginx
docker compose up -d # 去掉 Nginx(compose 会移除多余容器,数据不动)
⚠️ 代价:图片改为由 Nginx 直接返回后就不再经过 API,访问统计会缺失 (详见 访问统计的覆盖范围)。默认的两个容器形态统计才是完整的。它需要仓库根目录的
nginx.conf;用curl方式下载的话请补一句:curl -fsSLO https://raw.githubusercontent.com/praming/Glimmer/main/nginx.conf
启用 HTTPS: 把证书放到 ./certs/fullchain.pem 与 ./certs/privkey.pem,
取消 nginx.conf 末尾 443 段的注释,并把 .env 里的 COOKIE_SECURE 改回 true。
复制出来的直链形如 {对外地址}/{路径前缀}/{路径}(本地存储)。这两段分别按下面的规则取。
「路径前缀」(默认 files,可在**「设置 → 命名与域名 → 直链路径前缀」**改):
| 填写 | 直链形如 | 说明 |
|---|---|---|
files(默认) |
https://你的域名/files/2026/0919-xxx.webp |
与历史行为一致 |
img(任意词) |
https://你的域名/img/2026/0919-xxx.webp |
自定义前缀,保存即生效 |
| 留空 | https://你的域名/2026/0919-xxx.webp |
直接挂在根路径,完全去掉 /files |
前缀是服务端路由的一部分:改完保存,API 立即按新前缀提供文件、旧前缀同时失效,不需要重启容器。 若
.env里设了FILES_ROUTE_PREFIX,以环境变量为准(后台那个输入框会被锁定并给出提示)。
「对外地址」按下面的顺序取第一个有值的:
- 「设置 → 存储后端」里该后端的「访问域名」(原样使用:本地存储要自己写全,含路径前缀,如
https://你的域名/files;S3 / WebDAV 填 CDN 地址); - 「设置 → 命名与域名」里的自定义域名(全局兜底;它是根地址,本地存储会自动补上当前前缀);
- 环境变量
PUBLIC_BASE_URL(本地存储同样自动补当前前缀)。
所以最省事的做法是在「存储后端」里把访问域名填成 https://你的域名/files —— 不用改 .env,也不用重启容器,保存即生效。
改了路径前缀之后,历史图片的直链同样需要重写才生效(快照问题),见下一条。
⚠️ 直链是写入时的快照:改配置只影响之后新上传的图片,老图片的记录不会自动变。 换域名后要重写历史直链,见「常见问题 → 换域名后,历史图片直链还是旧地址?」里的rebuild-urls命令。
在**「设置 → 存储后端 → 添加后端 → S3」**里配置。三个字段的分工:
| 字段 | 填什么 |
|---|---|
| Endpoint | 控制台给出的服务域名:七牛 https://s3.cn-east-1.qiniucs.com、R2 https://<账号>.r2.cloudflarestorage.com、MinIO https://minio.example.com;AWS S3 留空即可(用 Region 推导)。不要填「空间域名」 |
| Bucket | 空间名 / 存储桶名。七牛要填**「S3 空间名」**(空间概览 → S3 域名 处可查看,一般等于空间名,全局重名时七牛会自动生成一个) |
| 路径前缀(可选) | 在命名规则之前多一层目录,要不要由你决定。留空 = 直接放在空间根目录 |
对象键(Key)就是这两段的拼接,没有别的隐藏层级:
{路径前缀?} / {命名规则} 例:img/2026/0919-7sgcq0.webp
所以「空间里凭空多出一个文件夹」只可能来自两处:路径前缀填了东西,或者 Endpoint 填成了空间域名。
⚠️ 七牛 Kodo 的两个地址别混淆。 控制台「空间概览 → S3 域名 → 点击查看」会同时给出两个地址:
- Endpoint(服务域名):
https://s3.cn-east-1.qiniucs.com← 填这个- 空间域名(虚拟主机风格):
https://<空间名>.s3.cn-east-1.qiniucs.com把后者填进 Endpoint、再打开「使用 Path-Style 访问」,对象键就会变成
<空间名>/<命名规则>。 本项目会自动忽略 Endpoint 里多出来的那段空间名,并在**「测试连接」**的结果里说明; 但直接把它改对更干净。各区域的 Endpoint 见七牛文档《服务域名》。「使用 Path-Style 访问」:七牛、MinIO 及多数自建服务建议开启;R2 / AWS S3 通常不需要。
⚠️ 事后修改「路径前缀」不会搬移已有文件。 数据库里存的是相对路径,前缀是取直链时按当前配置拼出来的, 所以改前缀会让老文件的直链整体平移、删除也会指向不存在的对象。前缀请在开始上传前定好; 已经传了再改,就得在对象存储侧把文件搬到新前缀下。
若该空间挂了 CDN 或自定义域名,把它填进这个后端的**「访问域名」**(如 https://cdn.example.com),
复制出来的直链即可直接走 CDN。
只用两条命令,适合不想引入 compose 的场景。容器名请保持 glimmer-api / glimmer-web
(前端按这个名字找后端;改了就要同步改 API_PROXY_TARGET)。
docker network create glimmer-net
mkdir -p glimmer-data/uploads glimmer-data/tmpdocker run -d --name glimmer-api --network glimmer-net --restart unless-stopped \
-v "$PWD/glimmer-data:/data/glimmer" \
-e NODE_ENV=production -e PORT=3000 -e TRUST_PROXY=true \
-e DATABASE_URL=/data/glimmer/glimmer.db \
-e LOCAL_STORAGE_DIR=/data/glimmer/uploads \
-e TEMP_DIR=/data/glimmer/tmp \
-e ADMIN_USERNAME=admin -e ADMIN_PASSWORD=换成你的密码 \
-e PUBLIC_BASE_URL=http://你的IP:3001 -e COOKIE_SECURE=false \
praming/glimmer-api:latestdocker run -d --name glimmer-web --network glimmer-net --restart unless-stopped \
-p 3001:3001 \
-e NODE_ENV=production -e NITRO_HOST=0.0.0.0 -e NITRO_PORT=3001 \
-e NUXT_PUBLIC_API_BASE=/api \
-e API_PROXY_TARGET=http://glimmer-api:3000 \
praming/glimmer-web:latest然后访问 http://你的IP:3001。
glimmer-api特意没有-p:它只在容器内网可达,公网无法直连 —— 这既是安全设计, 也是登录限流能正确识别访客 IP 的前提。所以跑docker run时请不要给它加-p 3000:3000。想换成自己构建的镜像,把
praming/前缀去掉(本地构建的 tag 就叫glimmer-api:latest)。上面没有传
ENCRYPTION_KEY:它会自动生成到glimmer-data/.secrets.json,也就是-v挂载的那个目录。 想自己指定就加-e ENCRYPTION_KEY=你的随机串。
面板自带的 OpenResty 可以接管对外端口,这时用另一份编排(端口只绑 127.0.0.1,不直接对外):
docker compose -f docker-compose.1panel.yml up -d面板侧只需两步:
-
新建一个反向代理站点,目标填
http://127.0.0.1:3001—— 一条规则就够, 因为前端自己会把/api与/files转给后端; -
在该站点配置里把上传体积上限调大:
client_max_body_size 64m;
⚠️ 面板默认是 1m,不改的话超过 1MB 的图会被面板直接拦成 413, 而且容器日志里什么都看不到,很容易误判成后端故障。
反向代理请勿使用面板的「静态网站」功能托管前端 —— 它的产物不是纯静态站点(见下方说明)。
升级到新版本:
docker compose pull && docker compose up -d --no-build # 用镜像升级
docker compose up -d --build # 从源码升级备份 —— 全部运行数据都在 ./glimmer-data,打包它即可,不需要停服务:
tar czf glimmer-backup-$(date +%F).tar.gz glimmer-data卸载(下面的命令会删除全部图片与数据库,请先备份):
docker compose down
rm -rf glimmer-data
⚠️ Node 版本必须是 18 / 20 / 22 / 23(推荐 22)
better-sqlite3是 ABI 绑定的原生模块,官方只发布了以上四个 ABI 的预编译二进制。用 Node 24+ 安装时找不到预编译包,会回落到node-gyp源码编译,没有 Visual Studio C++ 工具链就会失败。 仓库已内置守卫scripts/check-node.mjs(挂在preinstall),版本不受支持时会在下载依赖之前 直接中止并给出提示。版本声明同时写在.nvmrc与package.json的engines。
⚠️ 原生模块的 ABI 在安装期确定,因此「安装依赖」与「运行服务」必须使用同一个 Node 大版本。
node -v && pnpm -v # 需要 Node 18/20/22/23 + pnpm 9
pnpm install
cp .env.example .env # 可选:密钥会自动生成,本地开发一般只需改 ADMIN_PASSWORD
pnpm db:migrate # 建库 + 创建管理员(幂等,可重复执行)
pnpm dev # 同时启动 API(3000) 与 Web(3001)打开 http://localhost:3001 登录。Nuxt 开发服务器已通过 Nitro devProxy 把 /api/** 与 /files/**
代理到 127.0.0.1:3000,因此开发环境无需处理跨域。
也可以分开启动:pnpm dev:api(端口 3000)、pnpm dev:web(端口 3001)。
pnpm build # shared → api → web
pnpm start:api # node apps/api/dist/index.js
pnpm --filter @glimmer/web start # node apps/web/.output/server/index.mjs生产产物下 /api 与 /files 由 Nuxt(Nitro)自身转发给 API,与 Docker 里的形态一致。
容器部署时通过 API_PROXY_TARGET 指定后端地址(默认 http://glimmer-api:3000,纯本机运行可设为
http://127.0.0.1:3000):
API_PROXY_TARGET=http://127.0.0.1:3000 pnpm --filter @glimmer/web start注意:该转发只在生产构建下生效(开发环境由
devProxy负责)。 想在不启动 API 的情况下单独预览前端产物,用pnpm preview—— 它会自行拉起 nitro 与同源代理, 并在上游不可达时返回带原因的502,不会静默失败。端口可用PREVIEW_PORT/NITRO_PORT/API_PORT覆盖。
镜像由 GitHub Actions 自动构建并推送,配置见 .github/workflows/docker-publish.yml。
一次性准备
- 在 Docker Hub 生成访问令牌:Account settings → Personal access tokens → 权限选 Read & Write。
- 在 GitHub 仓库添加两个 secret(Settings → Secrets and variables → Actions):
DOCKERHUB_USERNAME:你的 Docker Hub 用户名DOCKERHUB_TOKEN:上一步生成的令牌 不要用登录密码 —— Docker Hub 已不支持密码推送。
- 两个仓库不必手动创建,首次推送时 Docker Hub 会自动建;但请到该仓库的 Settings 确认 可见性是 Public,否则别人拉不到镜像。
发布一个版本
git tag v1.0.0
git push origin v1.0.0 # 触发构建,推送 1.0.0 与 latest 两个标签| 触发方式 | 推送到 Docker Hub 的标签 |
|---|---|
推送 v* 标签 |
版本号(如 1.0.0)+ latest |
推送到 main(且 apps/**、packages/**、锁文件等有变化) |
latest |
| Actions 页面手动触发 | 自定义标签,留空则 latest |
发布的镜像只有 两个:glimmer-api 与 glimmer-web。
Nginx 用的是官方 nginx:alpine 镜像,不占用本项目的镜像标签 ——
所以「带不带 Nginx」不是靠拉取不同的镜像来区分的,而是靠 compose 的 --profile nginx(见上一节)。
为什么不用 Docker Hub 自带的自动构建? 它的 Automated Builds 已于 2026-05 宣布废弃 (2027-04-01 完全停用),且需要付费订阅;免 PAT 的 OIDC 登录也只对付费组织开放。 GitHub Actions 是 Docker 官方给出的迁移方向,而且一个仓库就能构建本项目这样的多个镜像, 这是 Docker Hub 原生方案做不到的(每个 repository 只能配一个 Dockerfile)。
镜像默认只构建
linux/amd64。需要 ARM 时把 workflow 里的platforms改为linux/amd64,linux/arm64并启用setup-qemu-action—— 注意在 QEMU 模拟下编译 native 模块(better-sqlite3 / sharp)会明显变慢。
所有配置都通过环境变量,每一项都有可用默认值,不建 .env 也能跑。完整清单与注释见 .env.example。关键项:
| 变量 | 默认 | 说明 |
|---|---|---|
DATABASE_URL |
./data/glimmer.db |
SQLite 文件路径(容器内建议 /data/glimmer/glimmer.db) |
LOCAL_STORAGE_DIR |
./data/uploads |
本地存储后端根目录 |
TEMP_DIR |
./data/tmp |
上传临时目录(处理完成后自动清理) |
ENCRYPTION_KEY |
自动生成 | 加密存储后端凭据(S3 Secret Key / WebDAV 密码)的主密钥。留空即首次启动自动生成并存到 glimmer-data/.secrets.json,请随数据一起备份 |
ADMIN_USERNAME |
admin |
首次启动创建的管理员用户名 |
ADMIN_PASSWORD |
change-me |
首次启动创建的管理员密码,务必修改 |
SESSION_TTL_DAYS |
7 |
默认会话有效期(天);用户可在个人资料里单独覆盖 |
COOKIE_SECURE |
生产为 true |
仅 HTTPS 下为 true;纯 HTTP 访问必须设为 false。.env.example 已预设 false |
PUBLIC_BASE_URL |
http://localhost:3000 |
图片直链域名的兜底值(本地存储自动补当前路径前缀)。优先级:存储后端→访问域名 > 设置→命名与域名→自定义域名 > 本变量 |
FILES_ROUTE_PREFIX |
留空(不覆盖) | 直链路径前缀的部署级强制值。留空 = 以「设置 → 命名与域名」为准;填 img 即强制用 /{img}/;填 / 强制挂在根路径。设置后后台的同名输入框会被锁定(页面会有提示) |
TRUST_PROXY |
生产为 true |
是否信任反代传来的 X-Forwarded-For。API 端口直连公网时必须设为 false |
MAX_UPLOAD_SIZE_MB |
20 |
单文件大小上限 |
QUEUE_CONCURRENCY |
2 |
异步队列并发数 |
CORS_ORIGIN |
http://localhost:3001 |
允许的跨域来源,逗号分隔 |
AUTH_RATE_LIMIT_MAX_PER_IP |
20 |
单个 IP 在窗口内的登录失败上限 |
AUTH_RATE_LIMIT_MAX_PER_ACCOUNT |
5 |
单个账号在窗口内的登录失败上限 |
AUTH_RATE_LIMIT_WINDOW_SECONDS |
900 |
限流窗口长度(秒) |
下面三项只在 Docker 部署时用到(它们是给 compose 做变量替换的,应用自身不读取):
| 变量 | 默认 | 说明 |
|---|---|---|
WEB_PORT |
3001 |
glimmer-web 的对外端口;填 80 即可用 http://IP 直接访问 |
API_PROXY_TARGET |
http://glimmer-api:3000 |
前端把 /api、/files 转发到哪个后端(改了容器名要同步改) |
IMAGE_PREFIX / IMAGE_TAG |
praming/ / latest |
镜像来源;把前缀留空即改用本地构建出的镜像 |
⚠️ ADMIN_PASSWORD只在users表为空的那一次启动里生效。账号一旦创建,改.env不会更新密码(启动日志会打印「已忽略 ADMIN_PASSWORD」)。日常改密码请到个人资料页; 已经登不进去时用docker exec glimmer-api node apps/api/dist/cli/reset-password.js重置 —— 不必删库(见「常见问题」)。启动时若检测到弱密钥或默认管理员密码,日志中会输出安全提示。
⚠️ 计流数据存在进程内存中,因此登录限流是单实例的:进程重启即清零(这同时也是「把自己锁在门外」的逃生口), 若将来横向扩成多实例,额度会被实例数放大,那时需要换成 Redis 之类的共享存储。
默认形态不需要任何反向代理 —— glimmer-web 自己会把 /api 与 /files 转发给 API。
另外两种形态都是可选的:
- 自带 Nginx:
docker compose --profile nginx up -d(见上文「可选:加上 Nginx」)。 它的nginx.conf做两件事:/与/api/*分流;location /files/直接alias到数据目录下的uploads,图片由 Nginx 直接返回、不消耗 Node 进程,未命中时回源 API。⚠️ 该location /files/与直链前缀是写死的对应关系:若你把路径前缀改成了别的值, 需同步改这个location,否则图片会绕过 Nginx 回源 API —— 功能照常,但少了 Nginx 直出的性能优势; 前缀留空(挂根路径)时则无法用location匹配,只能走回源。 - 面板反代(1Panel / 宝塔):见上文「可选:已经有 1Panel / 宝塔面板」。
glimmer-web 都不能省略:它是 Nitro node-server 产物,
.output/public/ 中只有 _nuxt/ 与 favicon.svg,没有 index.html
(HTML 入口由 Nitro 运行时生成),因此不能当作纯静态站点交给面板的「静态网站」功能托管。
统计的计数入口是 API 的 GET /files/* 路由,因此只有「经本项目后端返回的本地存储文件」会被统计:
| 部署形态 | 图片何时经 API | 统计 |
|---|---|---|
默认两容器(含面板只配 / 一条规则) |
总是 | 完整 ✅ |
启用 Nginx(--profile nginx) |
仅未命中磁盘时 | Nginx 直出的那部分不计入 ❌ |
面板反代并把 /{前缀} 单独指到 :3000 |
总是 | 完整 ✅ |
| S3 / WebDAV 后端 | 从不(由对象存储自有域名直出) | 不计入 ❌ |
也就是说:默认形态的统计是全量的;一旦让 Nginx 或对象存储直出图片,统计口径就只剩「经 API 的那部分」。 若需要精确的全量统计,建议在 Nginx access log 或 CDN 侧另行统计。
(统计采用内存聚合 + 定时落盘,满 5 秒或累计 200 个键刷新一次;304 命中不计次数与流量。)
限流计数与处理队列都在进程内存中。本项目按「一台 VPS、一个 API 进程」设计,未做多实例协调。
所有回包统一为 { data: ... } 或 { error: { code, message, details? } }。
除 /api/auth/* 与 /api/health 外均需认证;管理员接口额外校验角色。
认证支持两种方式,可混用:
- Cookie 会话(浏览器):
glimmer_session,由POST /api/auth/login下发。 - Bearer 令牌(脚本 / CI):
Authorization: Bearer glm_xxxxxxxx…,在「设置 → 存储后端 → 访问令牌」创建。
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
GET |
/api/health |
公开 | 健康检查 + 队列状态 |
POST |
/api/auth/login |
公开 | 登录 |
POST |
/api/auth/logout |
登录 | 退出并失效当前会话 |
GET |
/api/auth/me |
公开 | 当前用户 + 个人偏好(未登录返回 null) |
PATCH |
/api/auth/me |
登录 | 改自己的用户名 / 头像外链 / 会话有效期 |
POST |
/api/auth/password |
登录 | 改密(同时撤销名下全部令牌) |
POST |
/api/upload |
登录 | multipart 上传,返回 202 并进入异步队列 |
POST |
/api/upload/check |
登录 | 秒传预检:指纹命中则跳过文件传输 |
GET |
/api/upload/queue |
登录 | 队列运行状态 |
GET |
/api/images |
登录 | 分页 / 搜索 / 多条件筛选 |
GET |
/api/images/:id |
登录 | 详情(含变体与各后端状态) |
PATCH |
/api/images/:id |
本人或管理员 | 重命名(跨后端同步)/ 补充同步后端 |
DELETE |
/api/images/:id |
本人或管理员 | 同步删除所有后端并移除记录 |
POST |
/api/images/:id/retry |
本人或管理员 | 重试失败同步 / 补充指定后端 |
GET |
/api/images/:id/urls |
登录 | 各格式的四种复制文本 |
POST |
/api/images/batch/delete |
登录 | 批量删除(逐条鉴权) |
GET |
/api/stats |
登录 | 访问统计(管理员默认看全局,成员看自己) |
GET |
/api/settings |
管理员 | 全局配置(密钥以占位符回显)+ 运行信息 |
PATCH |
/api/settings |
管理员 | 保存全局配置 |
POST |
/api/settings/backends/test |
管理员 | 测试存储后端连通性 |
GET |
/api/settings/options |
登录 | 上传页所需的公开选项(不含密钥) |
GET |
/api/me/preferences |
登录 | 个人偏好 |
PATCH |
/api/me/preferences |
登录 | 保存个人偏好 |
GET |
/api/me/tokens |
登录 | 访问令牌列表(不含明文) |
POST |
/api/me/tokens |
登录 | 创建令牌,仅此一次返回明文 |
DELETE |
/api/me/tokens/:id |
登录 | 撤销令牌(软删除,保留审计记录) |
DELETE |
/api/me/tokens/:id?purge=1 |
登录 | 彻底删除该令牌记录(不可恢复) |
GET |
/api/users/:id/avatar |
登录 | 取头像图片(带 ?v= 版本号,可长缓存) |
POST |
/api/users/:id/avatar |
本人 | 上传头像(multipart,自动裁成 1:1 的 256×256 webp) |
DELETE |
/api/users/:id/avatar |
本人 | 移除头像(本地文件与外链一并清除) |
GET/POST/PATCH/DELETE |
/api/users[/:id] |
管理员 | 用户管理(创建 / 改角色 / 禁用 / 重置密码 / 删除) |
GET |
/{前缀}/* |
公开 | 本地存储后端静态文件(前缀默认为 files,可在后台改) |
# 登录(保存 Cookie)
curl -c cookie.txt -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"your-password"}'
# 上传(formats / backends 用逗号分隔)
curl -b cookie.txt -X POST http://localhost:3000/api/upload \
-F 'files=@photo.jpg' \
-F 'formats=webp,avif' \
-F 'backends=local' \
-F 'keepOriginal=false'
# → 202 {"data":{"images":[{"id":"...","status":"pending"}],"rejected":[]}}
# 轮询直到 ready
curl -b cookie.txt http://localhost:3000/api/images/<id>用 Bearer 令牌(适合脚本):
TOKEN=glm_xxxxxxxxxxxxxxxxxxxx
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/images
curl -H "Authorization: Bearer $TOKEN" -X POST http://localhost:3000/api/upload \
-F 'files=@photo.jpg' -F 'formats=webp' -F 'backends=local'秒传预检(先算原图 SHA-256,命中则完全跳过文件传输):
HASH=$(sha256sum photo.jpg | cut -d' ' -f1)
curl -b cookie.txt -X POST http://localhost:3000/api/upload/check \
-H 'Content-Type: application/json' \
-d "{\"hash\":\"$HASH\",\"size\":123456,\"formats\":[\"webp\"],\"backends\":[\"local\"],\"keepOriginal\":false}"
# → {"data":{"hit":true,"image":{ ...完整图片详情... }}}hit=true 时可直接使用回包里的 image(含各格式 URL),无需再调 /api/upload。
Q:docker compose up -d 卡在拉取,或报 manifest unknown / pull access denied?
说明镜像还没发布,或 Docker Hub 上的仓库是私有的。改用本地构建,结果完全一样,只是首次要多等几分钟:
docker compose up -d --buildQ:服务起来了,但浏览器打不开页面? 按顺序排查:
docker compose ps—— 两个容器是否都是running(glimmer-api显示healthy更好);docker compose logs glimmer-web—— 有没有明显的报错;- 云服务器的安全组 / 防火墙是否放行了
WEB_PORT(默认 3001)—— 这是最常见的原因; - 若把端口改成了 80,确认没被别的东西占用:
sudo ss -lntp | grep :80。
Q:想让网址不带端口(直接 http://IP)?
把 .env 里的 WEB_PORT=3001 改成 WEB_PORT=80,再 docker compose up -d 即可。
80 端口常被面板或其他服务占用,被占用时换个端口,或按「可选:加上 Nginx」那一节处理。
Q:上传大图失败 / 浏览器报 413?
① 本项目单文件上限默认 20MB,由 MAX_UPLOAD_SIZE_MB 控制;
② 如果前面有 1Panel / 宝塔面板,它的 client_max_body_size 默认只有 1m,必须调到 64m 或更大 ——
否则请求在面板层就被拦掉了,容器日志里不会有任何记录,极易误判成后端故障。
Q:登录提示「用户名或密码不正确」,或忘记管理员密码了? 先别急着怀疑自己记错 —— 绝大多数是你填的密码不是数据库里那个:
ADMIN_USERNAME/ADMIN_PASSWORD只在数据库为空的那一次启动里生效。 如果你是「先docker compose up -d跑起来,之后才建.env」,那么库里存的仍是change-me,后来写进.env的密码被静默忽略了(可查启动日志确认:docker compose logs glimmer-api | grep 已忽略)。.env必须和docker-compose.yml在同一个目录。面板部署时是面板的编排项目目录, 放在别处(例如你下载 yml 的那个目录)等于没配。- 用户名区分大小写,
Admin与admin是两个不同的账号名。
重置密码(不需删库、不需停服,新镜像自带该命令):
# 1) 先看一眼库里到底有哪些账号(用户名、角色、是否被禁用)
docker exec glimmer-api node apps/api/dist/cli/reset-password.js --list
# 2) 重置密码 —— 同时会撤销该账号的全部登录会话与 API 令牌
docker exec glimmer-api node apps/api/dist/cli/reset-password.js admin '你的新密码'
# 账号显示「已禁用」时,加 --enable 一并解除
docker exec glimmer-api node apps/api/dist/cli/reset-password.js admin '你的新密码' --enable新密码立即生效,无需重启;规则与界面一致(8 ~ 128 个字符)。
若提示找不到该文件,说明镜像还是旧的,拉一下即可:docker compose pull && docker compose up -d
连续输错 5 次会触发限流,此时报的是「尝试过于频繁,请在 N 秒后重试」而不是 「用户名或密码不正确」——两者是两回事:前者等 15 分钟(或重启 API 容器,额度在内存里) 再试,后者才是真的密码不对。
Q:pnpm install 报 gyp ERR! find VS Could not find any Visual Studio installation to use?
当前 Node 版本太新,better-sqlite3 没有对应 ABI 的预编译包。改用 Node 18 / 20 / 22 / 23(推荐 22),
并确保「安装依赖」和「运行服务」用的是同一个 Node 大版本。新版仓库已在 preinstall 阶段拦截该情况。
Q:登录成功但立刻掉登录态?
多半是 COOKIE_SECURE=true 但通过 HTTP 访问。纯 HTTP(例如 http://1.2.3.4)请设为 false,配好 HTTPS 后再改回 true。
Q:S3 上传成功,但空间里多了一个和空间同名的文件夹?
说明对象键被多套了一层。两个来源,对症处理:
- Endpoint 填成了「空间域名」(形如
https://<空间名>.s3.cn-east-1.qiniucs.com)。 这个地址本身已经把请求路由到了该空间,再开启 Path-Style 就会把主机名首段也算进 Key。 正确填法是服务域名:https://s3.cn-east-1.qiniucs.com。 本项目会自动忽略 Endpoint 里多余的空间名,并在「测试连接」的结果里说明。 - 「路径前缀」填了空间名。这项的用途是「命名规则之前要不要多一层目录」,不需要就留空。
配置改对之后,老数据怎么处理:
- 只是 Endpoint 填错(没填过路径前缀):老文件不用动。直链由「Endpoint + 空间名 + 相对路径」拼出, 改完仍指向同一个对象(那层多出来的目录本来就在 Key 里)。想让空间干净,只能手工把对象移到根目录 或删掉重新上传 —— 注意移动之后老直链会失效。
- 用过「路径前缀」:这类老记录的直链原本就与对象键对不上(已修),
跑一次
rebuild-urls --apply重写直链即可,不会移动任何文件。
Q:本地后端图片 404?
直链形如 {对外地址}/{路径前缀}/{路径}。先到**「设置 → 存储后端」看该后端的「访问域名」是否指向你的实际地址(后端「访问域名」是原样使用**、要自己写全路径前缀;它留空时才回落到「设置 → 命名与域名」的自定义域名、再到 .env 的 PUBLIC_BASE_URL,这两层会自动补上前缀);再确认路径前缀是否与「设置 → 命名与域名」里的一致(改过前缀却没跑 rebuild-urls,老图仍会指向旧路径);最后确认数据目录已正确挂载。域名填对了但老图片仍是旧地址属于「快照」问题,见下一条。
Q:换域名后,历史图片直链还是旧地址?
storage_records.url 是写入时的快照,所以改域名只影响之后新上传的图片,老记录不会自动变。
先把新域名配好(见 对外地址(图片直链域名)),再用自带命令一次性重写历史记录。
它默认是 dry-run,只打印不写库:
# 1) 先预览会改哪些(不写库)
docker exec glimmer-api node apps/api/dist/cli/rebuild-urls.js
# 2) 确认无误后落库(SQLite 为 WAL 模式,无需停服)
docker exec glimmer-api node apps/api/dist/cli/rebuild-urls.js --apply
# 可选:只改某个后端、或限制条数
docker exec glimmer-api node apps/api/dist/cli/rebuild-urls.js --backend <后端ID> --limit 100 --apply各后端 ID 可在「设置 → 存储后端」的卡片上看到。命令会按当前配置重新计算每条记录应有的直链,只更新真正变化的。
若「设置 → 命名与域名」里仍是写死的默认值 http://localhost:3000,它会打印警告并跳过 ——
此时请先改成你的实际域名(或重启一次 API,新版本会自动清理这个写死的默认值)。
Q:访问统计一直是 0?
见上方 访问统计的覆盖范围。若图片放在 S3/WebDAV,或线上由 Nginx 直服 /files/,
这些请求不经过 API,自然不计入。另外统计是内存聚合 + 每 5 秒落盘,刚访问完立刻刷新可能还没写库。
Q:上传同一张图没有触发秒传?
按可能性排查:① 访问方式不是安全上下文(crypto.subtle 只在 HTTPS 或 localhost 可用,局域网 IP + HTTP 会静默降级为普通上传,这是有意设计);
② 处理配置变了(秒传指纹包含输出格式、keepOriginal、目标后端、质量参数、最大宽高、命名模板,任一项不同即视为不同产物);
③ 换了账号(去重作用域是 per-user);④ 上次的产物是 failed(不参与去重)。
Q:登录时提示「尝试过于频繁,请在 N 秒后重试」?
触发了登录限流(默认单账号 5 次失败 / 15 分钟、单 IP 20 次失败 / 15 分钟)。等窗口过去即可,或重启 API 进程直接清零
(额度存在内存里,不落库)。也可以调大 AUTH_RATE_LIMIT_MAX_PER_ACCOUNT / AUTH_RATE_LIMIT_MAX_PER_IP。
Q:同一 IP 下的另一个同事被我的失败次数连累了?
这是刻意设计:IP 维度不区分账号(否则伪造来源即可拆分计数)。配额是「每窗口 20 次失败」,正常输入密码不会触发。
若部署在反向代理后面,还要确认 TRUST_PROXY=true,否则所有请求会被视为来自同一个内网 IP。
Q:令牌丢了怎么办? 无法找回 —— 服务端只存 SHA-256 摘要。请到「设置 → 存储后端 → 访问令牌」撤销后重新创建。 注意修改密码会自动撤销名下全部令牌,脚本需要同步更换。
Q:图片一直失败,详情里显示「自动重试已用尽配额」? 后台已按 30s → 2m → 8m → 32m 重试过 4 次仍未成功。通常是后端配置问题(密钥失效、bucket 不存在、WebDAV 密码过期、磁盘满)。 修正配置后在详情抽屉点「重试失败同步」即可 —— 手动重试会清零重试预算,重新获得完整的 4 次自动重试机会。
Q:S3 上传报 checksum / SignatureDoesNotMatch?
MinIO、R2 等对 AWS SDK 新版默认 checksum 敏感,本项目已设 requestChecksumCalculation: 'WHEN_REQUIRED'。
若仍失败,确认 forcePathStyle 与 region 是否与服务商文档一致(R2 常用 auto)。
Q:设置了字体但看不到变化? 项目不打包字体文件,只是把字体名写进 CSS。浏览器不会告诉你「这个字体没装」,只会安静回落, 因此最常见的原因就是本机没有这个字体。设置页内置了可用性检测,会直接写明「本机已安装 · 实际使用 X」或「本机未安装 · 已回落到 Y」。
glimmer/
├── apps/
│ ├── api/ # @glimmer/api —— Hono 服务
│ │ ├── src/
│ │ │ ├── index.ts # 启动入口(建表 → 恢复任务 → 监听)
│ │ │ ├── app.ts # 应用装配、中间件、统一错误处理
│ │ │ ├── env.ts # 环境变量校验与路径推导
│ │ │ ├── db/ # Drizzle schema / 连接 / 幂等建表
│ │ │ ├── lib/ # crypto / session / errors / http / tokens
│ │ │ ├── storage/ # local / s3 / webdav 适配器 + 注册表
│ │ │ ├── services/ # settings / pipeline / queue / images
│ │ │ │ # + dedup(秒传)/ access(计数)/ stats / retry
│ │ │ └── routes/ # auth / users / upload / images / settings / files / stats
│ │ └── Dockerfile
│ └── web/ # @glimmer/web —— Nuxt 3 前端
│ ├── pages/ # login / index(上传) / gallery / settings / users / profile
│ ├── layouts/ # default(侧边栏 + 底部 Tab)/ auth
│ ├── components/ # ui/* 基础组件 + 业务组件
│ ├── stores/ # auth / options / upload / gallery / settings
│ ├── composables/ # useApi / useToast / useTheme / useTypography …
│ └── server/ # Nitro 服务端代码
│ └── middleware/ # api-proxy.ts —— 生产环境把 /api、图片直链转发给 API
├── packages/
│ └── shared/ # @glimmer/shared —— 类型、常量、Zod schema、纯函数
├── scripts/ # 仓库级脚本(Node 版本守卫 / 预览服务 / 镜像发布)
├── docker-compose.yml # 默认:api + web 两个容器(nginx 是可选 profile,默认不启动不拉取)
├── docker-compose.1panel.yml # 面板反代场景:端口只绑回环,交给 1Panel / 宝塔
├── nginx.conf # 可选 Nginx 的配置(仅 --profile nginx 时被挂载)
├── .env.example
└── LICENSE
Copyright (C) 2026 Praming
本项目以 GNU Affero General Public License v3.0(AGPL-3.0) 授权发布,完整协议文本见 LICENSE。
| 场景 | 是否允许 | 需要做什么 |
|---|---|---|
| 自己 / 团队内部部署使用 | ✅ | 无需公开任何代码 |
| 修改后仅在内部使用 | ✅ | 无需公开任何代码 |
| 二次分发(无论是否修改) | ✅ | 附上协议全文、保留版权声明,并提供完整对应源代码 |
| 把修改版作为网络服务对外提供 | ✅ | 必须向使用者提供完整对应源代码(AGPL 比 GPL 多出的第 13 条) |
| 闭源商用 / 把衍生作品藏起来 | ❌ | —— |
一句话:内部怎么用都行;一旦对外提供网络服务,改动就必须开源。
第 13 条(Remote Network Interaction)是 AGPL 与 GPL 的唯一实质区别:它把「分发」的触发点扩大到「通过网络与之交互」。 所以自建图床给外部人用、且改过代码的话,需要提供源码。
第三方运行时依赖的许可均为宽松许可(MIT / ISC / Apache-2.0,如 Hono、Drizzle、sharp、better-sqlite3、Nuxt、Vue), 与 AGPL-3.0 兼容,不构成额外限制。