Repository Wiki
ChanIok/SpinningMomo

剪贴板、下载与分享能力

本页覆盖 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 是一个桌面端图库应用,用户查看媒体后有三个高频"导出"诉求:

  1. 复制到剪贴板——把选中的媒体文件(文件列表)放入系统剪贴板,供其他程序粘贴;反向也支持读取剪贴板中的文本或位图(用于粘贴搜索关键字、粘贴外部图片等场景)。
  2. 下载原文件——通过内置 HTTP 服务把图库中的原始媒体发送给浏览器/下载器,按资产 ID(正整数)定位文件,而不是信任客户端传来的磁盘路径。
  3. 下载多选归档——多选媒体打包为 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

Loading diagram...

架构分层要点:

  • 剪贴板与下载完全解耦。剪贴板封装位于 src/utils/system,是纯 Win32 封装层,返回 std::expected<T, std::string>,不依赖 HTTP 服务;下载能力位于 HTTP 服务与 gallery 特性层。两条通路唯一的共同点是都服务于"把媒体送出去"这一用户目标。
  • 下载路由不信任客户端路径。两条路由的 URL 参数分别是 asset_id(正整数,经 from_chars 严格校验)和 archive_name(归档逻辑名,由 acquire_archive_file 内部解析),磁盘路径一律由服务端从资产记录重新解析(downloads.cpp L68、L61 注释明确说明这一设计意图)。
  • 注册顺序有约束。routes.cpp L240 的注释指出"下载路由必须在静态 fallback 前注册,避免临时归档被当作前端资源处理"——如果顺序颠倒,/downloads/archives/... 会被 SPA 静态文件处理逻辑截获。

剪贴板子系统(src/utils/system)

公开契约

cpp
auto copy_files_to_clipboard(const std::vector<std::filesystem::path>& paths) -> std::expected<void, std::string>;

Source: system.hpp

把一组文件路径以"复制文件"语义(而非"移动文件")写入系统剪贴板。实现侧通过 CFSTR_PREFERREDDROPEFFECT 设定 DropEffect:

cpp
// 设置为“复制”而不是“移动”。这样其他程序在粘贴这些文件时会按“复制文件”来理解,而不是“移动文件”。 constexpr DWORD kClipboardDropEffectCopy = 1;

Source: system.cpp

设计意图:如果标记为"移动",某些目标程序(如资源管理器)粘贴成功后会删除源文件——对图库来说这是破坏性副作用。显式声明 DropEffect=Copy(值为 1)避免了这一点。

读取侧是两个函数:

cpp
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 之前完成拷贝。该函数把数据物化为自有缓冲,调用方在任意时刻使用都安全。

数据模型

cpp
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)、有文本(值)。

防御性上限

cpp
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)

路由注册

cpp
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 之前接入:

cpp
// 下载路由必须在静态 fallback 前注册,避免临时归档被当作前端资源处理。 core::http_server::downloads::register_routes(state, app);

Source: routes.cpp

统一鉴权入口

每个下载请求在进入业务逻辑前都先经过 has_download_access:

cpp
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 头。它负责判定本机回环/局域网来源与已登录会话——这正是本仓库"分享能力"的边界实现。失败响应刻意收敛为一个固定形态:

cpp
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 到文件

cpp
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:

cpp
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 一次性完成"查找 + 加租约":

cpp
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 的差异在最后两个实参:

  1. true —— 启用 Range 断点续传。多选归档通常体积较大,中断后浏览器可以用 Range: bytes=N- 续传,服务端按偏移继续发送。
  2. 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

Loading diagram...

时序说明:

  1. 鉴权先行——两个 handle_* 函数的第一条语句都是 has_download_access,任何失败立即短路返回,不进入资源解析,避免未授权请求触发磁盘查询或日志噪音。
  2. 错误内敛——ID 非法与资产缺失共用同一 404 响应,不区分原因;详细错误仅进入服务端 debug 日志。
  3. 所有权移交——stream_guard 以 std::move 传入静态服务层,此后路由代码不再触碰它;归档的存活期与 HTTP 响应流的存活期绑定。

剪贴板侧的读取时序相对简单(打开剪贴板 → 判格式 → 物化拷贝 → 关闭剪贴板 → 返回自有内存),其关键不变量已在上文"公开契约"一节说明。

Usage Examples

前端开发期代理(web/vite.config.ts)

typescript
'/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_nameZIP 流,支持 Range,带生命周期租约

Configuration Options

下载与剪贴板路径上的常量(均为编译期常量,非运行时可配置项):

常量类型默认值作用出处
kClipboardDropEffectCopyDWORD1标记剪贴板文件为"复制"而非"移动"system.cpp#L15
kMaxClipboardBitmapDimensionuint32_t100'000剪贴板位图单边像素上限system.cpp#L16
kMaxClipboardBitmapBytessize_t1 GiB剪贴板位图字节上限system.cpp#L17
开发代理端口—51206Vite 将 /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_idresolve_http_access文件流401 / 404(均为 no-store)
GET/downloads/archives/:archive_nameresolve_http_accessZIP 流(支持 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 时传入对应开关。
  • system.hpp —— 剪贴板公开契约与数据结构
  • system.cpp —— DropEffect 与剪贴板上限常量
  • downloads.cpp —— 两条下载路由的完整实现
  • routes.cpp —— 路由注册顺序约束
  • vite.config.ts —— 前端开发期下载代理
  • 访问控制(本机/局域网鉴权细节)与归档打包实现属于兄弟页面,本页仅引用其对外契约

Sources

(1 files)