剪贴板、下载与分享能力
本页覆盖 SpinningMomo 图库中"把媒体送出程序"的三条通路:系统剪贴板读写(src/utils/system)、HTTP 下载路由(src/core/http_server/downloads.cpp + src/features/gallery/download/download.hpp),以及基于访问控制的本机/局域网共享语义。
Purpose and Scope
本页覆盖:
- Windows 系统剪贴板封装:
copy_files_to_clipboard、read_clipboard_media、read_clipboard_text的契约、数据结构与安全上限(src/utils/system/system.hpp/system.cpp) - 图库下载 HTTP 路由
/downloads/assets/:asset_id与/downloads/archives/:archive_name的完整控制流(src/core/http_server/downloads.cpp) - 下载鉴权(
core::http_server::access::resolve_http_access)如何划分"本机 / 局域网"访问边界,即本仓库中"分享"的实际形态 - 归档下载的生命周期租约(活跃传输保护 + 空闲倒计时回收)与 Range 断点续传
- 前端开发期代理
/downloads指向本地服务端口(web/vite.config.ts)
留给兄弟页面的内容:
- 归档文件的生成(打包成 ZIP 的具体过程)属于
features/gallery/download内部实现,本页只描述其对外暴露的resolve_asset_file/acquire_archive_file契约 - 访问控制(PIN 码、Cookie、局域网开关)的完整策略在 access 模块,本页仅引用其入口签名;细节请参见对应目录页
- 媒体文件的静态服务管线
static_content::serve_download_file_request的 MIME/ETag 处理属于 HTTP 服务核心,本页只说明下载路由如何调用它
Overview
SpinningMomo 是一个桌面端图库应用,用户查看媒体后有三个高频"导出"诉求:
- 复制到剪贴板——把选中的媒体文件(文件列表)放入系统剪贴板,供其他程序粘贴;反向也支持读取剪贴板中的文本或位图(用于粘贴搜索关键字、粘贴外部图片等场景)。
- 下载原文件——通过内置 HTTP 服务把图库中的原始媒体发送给浏览器/下载器,按资产 ID(正整数)定位文件,而不是信任客户端传来的磁盘路径。
- 下载多选归档——多选媒体打包为 ZIP 临时归档后下载。归档是易变资源:有活跃传输时受保护,空闲一段时间后自动回收,因此下载需要先获取"租约"(lease),再由传输卫士(stream guard)托管连接生命周期。
"分享"在本仓库中的体现不是生成外链,而是访问边界的划分:下载路由统一经过 resolve_http_access 判定当前请求是来自本机还是局域网、是否携带有效 Cookie,未通过则返回统一的 401。这使得用户可以在同一网络内把图库"分享"给其他设备。
关键概念:
| 概念 | 含义 | 出处 |
|---|---|---|
| 资产 ID(asset_id) | 图库中媒体记录的正整数标识,下载路由据此反查磁盘路径 | downloads.cpp L31-43 |
| 归档租约(lease) | acquire_archive_file 返回的 {file, stream_guard},保证传输期间归档不被回收 | downloads.cpp L87-98 |
| 传输卫士(stream_guard) | 归档下载时移交给静态服务的生命周期守卫,负责连接断开后倒计时回收 | downloads.cpp L95-98 |
ClipboardMedia | 剪贴板读取结果的和类型:空 / 文本 / PNG 字节 + 位图元数据 | system.hpp L56-61 |
Architecture
架构分层要点:
- 剪贴板与下载完全解耦。剪贴板封装位于
src/utils/system,是纯 Win32 封装层,返回std::expected<T, std::string>,不依赖 HTTP 服务;下载能力位于 HTTP 服务与 gallery 特性层。两条通路唯一的共同点是都服务于"把媒体送出去"这一用户目标。 - 下载路由不信任客户端路径。两条路由的 URL 参数分别是
asset_id(正整数,经from_chars严格校验)和archive_name(归档逻辑名,由acquire_archive_file内部解析),磁盘路径一律由服务端从资产记录重新解析(downloads.cppL68、L61 注释明确说明这一设计意图)。 - 注册顺序有约束。
routes.cppL240 的注释指出"下载路由必须在静态 fallback 前注册,避免临时归档被当作前端资源处理"——如果顺序颠倒,/downloads/archives/...会被 SPA 静态文件处理逻辑截获。
剪贴板子系统(src/utils/system)
公开契约
auto copy_files_to_clipboard(const std::vector<std::filesystem::path>& paths)
-> std::expected<void, std::string>;Source: system.hpp
把一组文件路径以"复制文件"语义(而非"移动文件")写入系统剪贴板。实现侧通过 CFSTR_PREFERREDDROPEFFECT 设定 DropEffect:
// 设置为“复制”而不是“移动”。这样其他程序在粘贴这些文件时会按“复制文件”来理解,而不是“移动文件”。
constexpr DWORD kClipboardDropEffectCopy = 1;Source: system.cpp
设计意图:如果标记为"移动",某些目标程序(如资源管理器)粘贴成功后会删除源文件——对图库来说这是破坏性副作用。显式声明 DropEffect=Copy(值为 1)避免了这一点。
读取侧是两个函数:
1// 一次性复制系统剪贴板中的文件列表或位图数据,让调用方在剪贴板关闭后安全使用。
2auto read_clipboard_media() -> std::expected<ClipboardMedia, std::string>;
3
4// 读取系统剪贴板中的纯文本内容(UTF-8)
5auto read_clipboard_text() -> std::expected<std::optional<std::string>, std::string>;Source: system.hpp
注意 read_clipboard_media 的注释措辞:"一次性复制……让调用方在剪贴板关闭后安全使用"。这是 Win32 剪贴板的经典陷阱:GetClipboardData 返回的内存归剪贴板所有,必须在 CloseClipboard 之前完成拷贝。该函数把数据物化为自有缓冲,调用方在任意时刻使用都安全。
数据模型
1enum class ClipboardMediaKind {
2 Empty,
3 ...
4};
5
6struct ClipboardBitmap {
7 std::uint32_t width = 0;
8 ...
9};
10
11struct ClipboardMedia {
12 ClipboardMediaKind kind = ClipboardMediaKind::Empty;
13 std::vector<std::uint8_t> encoded_png;
14 std::optional<ClipboardBitmap> bitmap;
15};Source: system.hpp
ClipboardMedia 是一个和类型:kind 判空/判类型;位图场景下同时携带 encoded_png(可直接落盘或回传前端的 PNG 字节)与 bitmap(宽高等元数据)。read_clipboard_text 的返回是 std::optional<std::string> 内嵌于 expected,即三层语义:错误(打开剪贴板失败等)、空剪贴板(nullopt)、有文本(值)。
防御性上限
constexpr std::uint32_t kMaxClipboardBitmapDimension = 100'000;
constexpr std::size_t kMaxClipboardBitmapBytes = 1024ULL * 1024ULL * 1024ULL;Source: system.cpp
读取剪贴板位图前先校验尺寸上限(单边 ≤ 100 000 像素)与字节上限(≤ 1 GiB)。恶意或异常的剪贴板内容(例如超大 DIB)不会导致进程按位图头声明的大小直接分配内存,避免 OOM。这两个常量是未命名命名空间外可见的模块内常量,调用方无法覆盖,属于硬性保护。
下载路由(core::http_server::downloads)
路由注册
1auto register_routes(core::AppState& state, uWS::App& app) -> void {
2 Logger().info("Registering gallery download routes");
3 app.get("/downloads/assets/:asset_id",
4 [&state](auto* res, auto* req) { handle_asset_download(state, res, req); });
5 app.get("/downloads/archives/:archive_name",
6 [&state](auto* res, auto* req) { handle_archive_download(state, res, req); });
7}Source: downloads.cpp
两条 GET 路由,由 routes.cpp 在静态 fallback 之前接入:
// 下载路由必须在静态 fallback 前注册,避免临时归档被当作前端资源处理。
core::http_server::downloads::register_routes(state, app);Source: routes.cpp
统一鉴权入口
每个下载请求在进入业务逻辑前都先经过 has_download_access:
1auto has_download_access(core::AppState& state, auto* res, auto* req) -> bool {
2 if (core::http_server::access::resolve_http_access(state, res->getRemoteAddressAsText(),
3 req->getHeader("cookie"))) {
4 return true;
5 }
6 reject_unauthorized(res);
7 return false;
8}Source: downloads.cpp
resolve_http_access 接收三个输入:全局 AppState、远端地址文本、Cookie 头。它负责判定本机回环/局域网来源与已登录会话——这正是本仓库"分享能力"的边界实现。失败响应刻意收敛为一个固定形态:
1auto reject_unauthorized(auto* res) -> void {
2 res->writeStatus("401 Unauthorized");
3 res->writeHeader("Cache-Control", "no-store");
4 res->writeHeader("Content-Type", "text/plain; charset=utf-8");
5 res->end("Authentication required");
6}Source: downloads.cpp
Cache-Control: no-store 防止鉴权失败的 401 被中间层/浏览器缓存复用;正文不含任何可探测信息。
资产下载:从 ID 到文件
1auto handle_asset_download(core::AppState& state, auto* res, auto* req) -> void {
2 if (!has_download_access(state, res, req)) {
3 return;
4 }
5
6 // 只接受正整数 ID,避免把 URL 参数直接当成路径使用。
7 const auto asset_id = resolve_asset_id(req->getParameter("asset_id"));
8 if (!asset_id) {
9 reject_not_found(res);
10 return;
11 }
12
13 // 根据资产记录重新解析磁盘路径,下载路由不信任客户端传入的文件路径。
14 auto file_result = features::gallery::download::resolve_asset_file(state, *asset_id);
15 if (!file_result) {
16 Logger().debug("Gallery asset download was not found for {}: {}", *asset_id,
17 file_result.error());
18 reject_not_found(res);
19 return;
20 }
21
22 static_content::serve_download_file_request(state, file_result->file_path,
23 std::move(file_result->file_name), res, req);
24}Source: downloads.cpp
ID 解析使用 std::from_chars 并要求整段消费、值 > 0:
1auto resolve_asset_id(std::string_view value) -> std::optional<std::int64_t> {
2 if (value.empty()) {
3 return std::nullopt;
4 }
5
6 std::int64_t asset_id = 0;
7 const auto [pointer, error] =
8 std::from_chars(value.data(), value.data() + value.size(), asset_id);
9 if (error != std::errc{} || pointer != value.data() + value.size() || asset_id <= 0) {
10 return std::nullopt;
11 }
12 return asset_id;
13}Source: downloads.cpp
三重校验(无 errc 错误、指针到达串尾、正整数)排除了 "12abc"、"-1"、"0"、空串等所有绕过形态,因此后续流程中 asset_id 永远是安全的 int64_t,不存在路径注入面。
归档下载:租约与生命周期
多选下载的核心不同点在于:归档是临时资源,存在"正在被下载"与"等待回收"两种状态。路由层通过 acquire_archive_file 一次性完成"查找 + 加租约":
1auto handle_archive_download(core::AppState& state, auto* res, auto* req) -> void {
2 if (!has_download_access(state, res, req)) {
3 return;
4 }
5
6 auto lease_result =
7 features::gallery::download::acquire_archive_file(state, req->getParameter("archive_name"));
8 if (!lease_result) {
9 Logger().debug("Gallery archive download was not found: {}", lease_result.error());
10 reject_not_found(res);
11 return;
12 }
13
14 // 归档支持 Range 断点续传;传输卫士负责在连接断开后倒计时回收。
15 static_content::serve_download_file_request(state, lease_result->file.file_path,
16 std::move(lease_result->file.file_name), res, req,
17 true, std::move(lease_result->stream_guard));
18}Source: downloads.cpp
与资产下载调用 serve_download_file_request 的差异在最后两个实参:
true—— 启用 Range 断点续传。多选归档通常体积较大,中断后浏览器可以用Range: bytes=N-续传,服务端按偏移继续发送。std::move(lease_result->stream_guard)—— 把传输卫士的所有权移交给静态服务层。stream_guard是租约的活动部分:只要传输连接仍活跃,归档就受保护不被回收;连接断开(正常结束或异常中断)后,由卫士启动空闲倒计时,倒计时归零才真正删除临时归档。这一设计避免了大文件传输中途归档被清理导致 404 的问题,同时保证没有活跃流量的临时文件不会无限堆积。
为什么资产下载不需要租约? 资产对应的原始媒体文件是图库的持久数据,不存在"回收"语义;归档则是为一次多选操作临时生成的 ZIP,必须回收。两条路由在生命周期管理上的不对称正是这个原因。
acquire_archive_file 与 resolve_asset_file 返回的是 expected,错误路径统一走 reject_not_found(404,Cache-Control: no-store,正文固定为 "Download not found")并记录 debug 日志——注意日志不回传给客户端,404 文案不区分"归档已被回收"与"名称非法",避免向外部探测者泄露归档目录状态。
Core Flow
时序说明:
- 鉴权先行——两个
handle_*函数的第一条语句都是has_download_access,任何失败立即短路返回,不进入资源解析,避免未授权请求触发磁盘查询或日志噪音。 - 错误内敛——ID 非法与资产缺失共用同一 404 响应,不区分原因;详细错误仅进入服务端 debug 日志。
- 所有权移交——
stream_guard以std::move传入静态服务层,此后路由代码不再触碰它;归档的存活期与 HTTP 响应流的存活期绑定。
剪贴板侧的读取时序相对简单(打开剪贴板 → 判格式 → 物化拷贝 → 关闭剪贴板 → 返回自有内存),其关键不变量已在上文"公开契约"一节说明。
Usage Examples
前端开发期代理(web/vite.config.ts)
'/downloads': {
target: 'http://localhost:51206',Source: vite.config.ts
开发态下前端把 /downloads 代理到本地 C++ 服务的 51206 端口,因此前端代码可以用相对路径直接请求下载接口,无需区分环境。
调用形态汇总(依据源码证据)
| 场景 | 入口 | 关键返回/副作用 |
|---|---|---|
| 复制文件到剪贴板 | copy_files_to_clipboard(paths) | DropEffect=Copy,避免目标程序删除源文件 |
| 读取剪贴板媒体 | read_clipboard_media() | 物化后的 PNG 字节 + 位图元数据;受尺寸/字节上限保护 |
| 读取剪贴板文本 | read_clipboard_text() | UTF-8 文本,可为 nullopt |
| 下载单个资产 | GET /downloads/assets/:asset_id | 原始媒体文件流 |
| 下载多选归档 | GET /downloads/archives/:archive_name | ZIP 流,支持 Range,带生命周期租约 |
Configuration Options
下载与剪贴板路径上的常量(均为编译期常量,非运行时可配置项):
| 常量 | 类型 | 默认值 | 作用 | 出处 |
|---|---|---|---|---|
kClipboardDropEffectCopy | DWORD | 1 | 标记剪贴板文件为"复制"而非"移动" | system.cpp#L15 |
kMaxClipboardBitmapDimension | uint32_t | 100'000 | 剪贴板位图单边像素上限 | system.cpp#L16 |
kMaxClipboardBitmapBytes | size_t | 1 GiB | 剪贴板位图字节上限 | system.cpp#L17 |
| 开发代理端口 | — | 51206 | Vite 将 /downloads 转发到本地服务 | vite.config.ts#L34 |
下载鉴权的实际开关(局域网分享、会话 Cookie 策略)由 access 模块读取 AppState 决定,属于访问控制页面的范围,此处不展开。
API Reference
copy_files_to_clipboard(paths: std::vector<std::filesystem::path>) -> std::expected<void, std::string>
参数: paths —— 要复制进剪贴板的文件路径集合。
返回: 成功为 void;失败时 expected 携带描述性错误字符串(如打开剪贴板失败)。
副作用: 替换系统剪贴板内容,并写入 CFSTR_PREFERREDDROPEFFECT = DROPEFFECT_COPY。
read_clipboard_media() -> std::expected<ClipboardMedia, std::string>
返回: ClipboardMedia{kind, encoded_png, bitmap};数据已物化为自有内存,剪贴板关闭后仍可安全使用。
防御: 位图超过 kMaxClipboardBitmapDimension 或 kMaxClipboardBitmapBytes 时不会按声明大小分配。
read_clipboard_text() -> std::expected<std::optional<std::string>, std::string>
返回: 错误 / 空剪贴板(nullopt)/ UTF-8 文本三层语义。
resolve_asset_file(state, asset_id) -> std::expected<{file_path, file_name}, ...>
由 features::gallery::download 提供。依据资产记录在服务端解析磁盘路径与下载文件名,路由层不拼接客户端可控的路径。
acquire_archive_file(state, archive_name) -> std::expected<{file, stream_guard}, ...>
按逻辑名获取临时归档并附带生命周期租约;stream_guard 需随文件一并移交静态服务层,活跃传输期间阻止回收,断开后启动空闲倒计时。
HTTP 端点
| 方法 | 路径 | 鉴权 | 成功 | 失败 |
|---|---|---|---|---|
| GET | /downloads/assets/:asset_id | resolve_http_access | 文件流 | 401 / 404(均为 no-store) |
| GET | /downloads/archives/:archive_name | resolve_http_access | ZIP 流(支持 Range) | 401 / 404(均为 no-store) |
Failure Modes, Edge Cases & Concurrency
- 路径信任边界:两条路由都不接受客户端提供的磁盘路径;资产按 ID 反查,归档按逻辑名获取。
resolve_asset_id的from_chars+ 完整消费 + 正整数三重校验消除了asset_id参数上的注入面。 - 404 与 401 的信息收敛:所有失败响应正文固定("Download not found" / "Authentication required"),并统一
Cache-Control: no-store,既防止缓存复用,也不向探测者泄露归档存活状态。详细错误只进服务端Logger().debug。 - 归档回收竞态:临时归档可能恰好在客户端请求前被回收。此场景由
acquire_archive_file的expected错误路径承接,表现为 404,而非半开的流或崩溃。 - 大文件中断:归档传输启用 Range 断点续传;连接异常断开时
stream_guard转入空闲倒计时而不是立即删除,支持客户端重连续传;若客户端不再回来,倒计时归零后清理,防止临时目录膨胀。 - 剪贴板所有权陷阱:Win32
GetClipboardData返回的内存归剪贴板所有,read_clipboard_media在关闭剪贴板前完成拷贝(源码注释明确此契约),调用方零风险。 - 异常剪贴板内容:超上限位图被拒绝按声明大小分配,防 OOM。
- DropEffect 语义:复制文件时显式写
DROPEFFECT_COPY,防止粘贴方按"移动"处理而删除图库源文件。
Performance & Operational Notes
- 下载发送统一交给
static_content::serve_download_file_request,基于 uWebSockets 的异步流式发送,路由层本身只做鉴权与资源解析,保持轻量。 - 归档租约机制是操作层面的核心保障:活跃保护 + 空闲回收的组合,使"多选打包"不会留下孤儿 ZIP 文件,也不需要人工清理任务。
- 路由注册顺序(静态 fallback 之前)是一次性部署正确性要求,改动
routes.cpp时需保持该顺序。 - 日志策略:资产/归档未命中仅 debug 级别,避免局域网内扫描行为刷屏。
Extension Points
- 新增下载形态时,建议复用既有三段式骨架:
has_download_access鉴权 → 服务端解析资源(不信任客户端路径)→serve_download_file_request发送;临时资源参照归档的"获取即租约"模式。 - 剪贴板扩展(例如写入文本/位图而非文件)应落在
src/utils/system/system.hpp的同一std::expected契约风格下,保持错误字符串描述、数据物化、防御性上限三条既有约定。 - 归档支持 Range 的能力由静态服务层提供,任何需要断点续传的新资源类型只需在调用
serve_download_file_request时传入对应开关。
Related Links
- system.hpp —— 剪贴板公开契约与数据结构
- system.cpp —— DropEffect 与剪贴板上限常量
- downloads.cpp —— 两条下载路由的完整实现
- routes.cpp —— 路由注册顺序约束
- vite.config.ts —— 前端开发期下载代理
- 访问控制(本机/局域网鉴权细节)与归档打包实现属于兄弟页面,本页仅引用其对外契约