Repository: ai-tmarks/tmarks Branch: main Commit: 6d976e5e7713 Files: 383 Total size: 1.5 MB Directory structure: gitextract_hsh8svta/ ├── DEPLOYMENT.md ├── LICENSE ├── README.md └── tmarks/ ├── .gitignore ├── .prettierignore ├── .prettierrc ├── API-DATABASE-AUDIT.md ├── eslint.config.js ├── functions/ │ ├── api/ │ │ ├── _middleware.ts │ │ ├── index.ts │ │ ├── public/ │ │ │ └── [slug].ts │ │ ├── share/ │ │ │ └── [token].ts │ │ ├── shared/ │ │ │ └── cache.ts │ │ ├── snapshot-images/ │ │ │ └── [hash].ts │ │ ├── tab/ │ │ │ ├── bookmarks/ │ │ │ │ ├── [id]/ │ │ │ │ │ ├── click.ts │ │ │ │ │ ├── permanent.ts │ │ │ │ │ ├── restore.ts │ │ │ │ │ ├── snapshot-upload.ts │ │ │ │ │ ├── snapshots/ │ │ │ │ │ │ ├── [snapshotId].ts │ │ │ │ │ │ └── cleanup.ts │ │ │ │ │ ├── snapshots-v2.ts │ │ │ │ │ ├── snapshots.ts │ │ │ │ │ └── trash.ts │ │ │ │ ├── [id].ts │ │ │ │ ├── batch/ │ │ │ │ │ └── index.ts │ │ │ │ ├── batch-handler.ts │ │ │ │ ├── bookmark-batch.ts │ │ │ │ ├── bookmark-list.ts │ │ │ │ ├── index.ts │ │ │ │ ├── reorder-pinned.ts │ │ │ │ ├── trash/ │ │ │ │ │ └── empty.ts │ │ │ │ └── trash.ts │ │ │ ├── me.ts │ │ │ ├── search.ts │ │ │ ├── statistics/ │ │ │ │ └── index.ts │ │ │ ├── tab-groups/ │ │ │ │ ├── [id]/ │ │ │ │ │ ├── items/ │ │ │ │ │ │ └── batch.ts │ │ │ │ │ ├── permanent-delete.ts │ │ │ │ │ ├── restore.ts │ │ │ │ │ └── share.ts │ │ │ │ ├── [id].ts │ │ │ │ ├── index.ts │ │ │ │ ├── items/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ └── move.ts │ │ │ │ │ └── [id].ts │ │ │ │ └── trash.ts │ │ │ └── tags/ │ │ │ ├── [id]/ │ │ │ │ └── click.ts │ │ │ ├── [id].ts │ │ │ └── index.ts │ │ └── v1/ │ │ ├── auth/ │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ ├── refresh.ts │ │ │ └── register.ts │ │ ├── bookmarks/ │ │ │ ├── [id]/ │ │ │ │ ├── click.ts │ │ │ │ ├── permanent.ts │ │ │ │ ├── restore.ts │ │ │ │ ├── snapshot-cleanup.ts │ │ │ │ ├── snapshots/ │ │ │ │ │ ├── [snapshotId]/ │ │ │ │ │ │ └── view.ts │ │ │ │ │ ├── [snapshotId].ts │ │ │ │ │ └── cleanup.ts │ │ │ │ └── snapshots.ts │ │ │ ├── [id].ts │ │ │ ├── bulk.ts │ │ │ ├── index.ts │ │ │ ├── statistics-helpers.ts │ │ │ ├── statistics.ts │ │ │ ├── trash/ │ │ │ │ └── empty.ts │ │ │ └── trash.ts │ │ ├── change-password.ts │ │ ├── export.ts │ │ ├── health.ts │ │ ├── preferences-helpers.ts │ │ ├── preferences.ts │ │ ├── settings/ │ │ │ ├── api-keys/ │ │ │ │ ├── [id].ts │ │ │ │ └── index.ts │ │ │ ├── share.ts │ │ │ └── storage.ts │ │ ├── shared/ │ │ │ └── cache.ts │ │ ├── statistics/ │ │ │ └── index.ts │ │ ├── tab-groups/ │ │ │ ├── [id]/ │ │ │ │ ├── items/ │ │ │ │ │ └── batch.ts │ │ │ │ ├── permanent-delete.ts │ │ │ │ ├── restore.ts │ │ │ │ └── share.ts │ │ │ ├── [id].ts │ │ │ ├── batch-update.ts │ │ │ ├── index.ts │ │ │ ├── items/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── move.ts │ │ │ │ └── [id].ts │ │ │ └── trash.ts │ │ └── tags/ │ │ ├── [id].ts │ │ └── index.ts │ ├── lib/ │ │ ├── api-key/ │ │ │ ├── generator.ts │ │ │ ├── logger.ts │ │ │ ├── rate-limiter-types.ts │ │ │ ├── rate-limiter.ts │ │ │ └── validator.ts │ │ ├── bookmark-utils.ts │ │ ├── cache/ │ │ │ ├── README.md │ │ │ ├── bookmark-cache.ts │ │ │ ├── config.ts │ │ │ ├── index.ts │ │ │ ├── service.ts │ │ │ ├── strategies.ts │ │ │ └── types.ts │ │ ├── config.ts │ │ ├── crypto.ts │ │ ├── data-fetchers.ts │ │ ├── error-handler.ts │ │ ├── image-sig.ts │ │ ├── image-upload.ts │ │ ├── import-export/ │ │ │ ├── collect-export-data.ts │ │ │ ├── export-scope.ts │ │ │ ├── export-stats.ts │ │ │ └── exporters/ │ │ │ ├── html-exporter.ts │ │ │ ├── json-exporter.ts │ │ │ └── tab-groups-netscape.ts │ │ ├── index.ts │ │ ├── input-sanitizer.ts │ │ ├── jwt.ts │ │ ├── rate-limit.ts │ │ ├── response.ts │ │ ├── signed-url.ts │ │ ├── storage-quota.ts │ │ ├── tags.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── validation.ts │ └── middleware/ │ ├── api-key-auth-pages.ts │ ├── api-key-auth.ts │ ├── auth.ts │ ├── dual-auth.ts │ ├── index.ts │ └── security.ts ├── index.html ├── migrations/ │ ├── 0001_d1_console.sql │ ├── 0002_d1_console_ai_settings.sql │ ├── 0103_api_key_rate_limits.sql │ └── 0104_rate_limits.sql ├── package.json ├── postcss.config.js ├── public/ │ ├── _headers │ └── _routes.json ├── scripts/ │ ├── auto-migrate.js │ ├── check-db-schema.js │ ├── check-migrations.js │ └── prepare-deploy.js ├── shared/ │ ├── import-export-types.ts │ └── permissions.ts ├── src/ │ ├── App.tsx │ ├── components/ │ │ ├── api-keys/ │ │ │ ├── ApiKeyCard.tsx │ │ │ ├── ApiKeyDetailModal.tsx │ │ │ ├── CreateApiKeyModal.tsx │ │ │ ├── StepBasicInfo.tsx │ │ │ ├── StepPermissions.tsx │ │ │ └── StepSuccess.tsx │ │ ├── auth/ │ │ │ └── ProtectedRoute.tsx │ │ ├── bookmarks/ │ │ │ ├── BatchActionBar.tsx │ │ │ ├── BookmarkCardView.tsx │ │ │ ├── BookmarkForm.tsx │ │ │ ├── BookmarkListContainer.tsx │ │ │ ├── BookmarkListItem.tsx │ │ │ ├── BookmarkListLayout.tsx │ │ │ ├── BookmarkListView.tsx │ │ │ ├── BookmarkMinimalListView.tsx │ │ │ ├── BookmarkTitleView.tsx │ │ │ ├── DefaultBookmarkIcon.tsx │ │ │ ├── SnapshotViewer.tsx │ │ │ ├── TagSelector.tsx │ │ │ ├── bookmark-utils.ts │ │ │ ├── defaultIconOptions.ts │ │ │ ├── hooks/ │ │ │ │ └── useBookmarkFormState.ts │ │ │ ├── shared/ │ │ │ │ ├── BookmarkActions.tsx │ │ │ │ ├── BookmarkTagList.tsx │ │ │ │ ├── MasonryGrid.tsx │ │ │ │ ├── useFaviconFallback.ts │ │ │ │ └── useResponsiveColumns.ts │ │ │ ├── useBookmarkForm.ts │ │ │ └── useSnapshots.ts │ │ ├── common/ │ │ │ ├── AdaptiveImage.tsx │ │ │ ├── AlertDialog.tsx │ │ │ ├── BookmarkIcons.tsx │ │ │ ├── BottomNav.tsx │ │ │ ├── CircularProgress.tsx │ │ │ ├── ColorThemeSelector.tsx │ │ │ ├── ConfirmDialog.tsx │ │ │ ├── DialogHost.tsx │ │ │ ├── DragDropUpload.tsx │ │ │ ├── Drawer.tsx │ │ │ ├── DropdownMenu.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── ErrorDisplay.tsx │ │ │ ├── LanguageSelector.tsx │ │ │ ├── LazyImage.tsx │ │ │ ├── MobileHeader.tsx │ │ │ ├── PaginationFooter.tsx │ │ │ ├── ProgressIndicator.tsx │ │ │ ├── ResizablePanel.tsx │ │ │ ├── SearchToolbar.tsx │ │ │ ├── SimpleProgress.tsx │ │ │ ├── SortSelector.tsx │ │ │ ├── ThemeToggle.tsx │ │ │ ├── Toast.tsx │ │ │ ├── Toggle.tsx │ │ │ ├── progressUtils.ts │ │ │ └── useAnimatedProgress.ts │ │ ├── import-export/ │ │ │ ├── ExportOptionsForm.tsx │ │ │ └── ExportSection.tsx │ │ ├── layout/ │ │ │ ├── AppShell.tsx │ │ │ ├── FullScreenAppShell.tsx │ │ │ ├── MobileBottomNav.tsx │ │ │ ├── PublicAppShell.tsx │ │ │ ├── ShellHeader.tsx │ │ │ └── ThemedRoot.tsx │ │ ├── settings/ │ │ │ ├── InfoBox.tsx │ │ │ ├── SearchAutoClearSettings.tsx │ │ │ ├── SettingsNav.tsx │ │ │ ├── SettingsSaveBar.tsx │ │ │ ├── SettingsSection.tsx │ │ │ ├── SettingsTips.tsx │ │ │ ├── TagSelectionAutoClearSettings.tsx │ │ │ └── tabs/ │ │ │ ├── ApiSettingsTab.tsx │ │ │ ├── AutomationSettingsTab.tsx │ │ │ ├── BasicSettingsTab.tsx │ │ │ ├── BrowserSettingsTab.tsx │ │ │ ├── DataSettingsTab.tsx │ │ │ ├── ShareSettingsTab.tsx │ │ │ └── SnapshotSettingsTab.tsx │ │ ├── tab-groups/ │ │ │ ├── BatchActionBar.tsx │ │ │ ├── ColorPicker.tsx │ │ │ ├── EmptyState.tsx │ │ │ ├── InsertionIndicator.tsx │ │ │ ├── MoveItemDialog.tsx │ │ │ ├── MoveToFolderDialog.tsx │ │ │ ├── PinnedItemsSection.tsx │ │ │ ├── SearchBar.tsx │ │ │ ├── ShareDialog.tsx │ │ │ ├── SortSelector.tsx │ │ │ ├── TabGroupCard.tsx │ │ │ ├── TabGroupHeader.tsx │ │ │ ├── TabGroupSidebar.tsx │ │ │ ├── TabGroupTree.tsx │ │ │ ├── TabItem.tsx │ │ │ ├── TabItemList.tsx │ │ │ ├── TagsInput.tsx │ │ │ ├── TodoItemCard.tsx │ │ │ ├── TodoSidebar.tsx │ │ │ ├── colorUtils.ts │ │ │ ├── sortUtils.ts │ │ │ └── tree/ │ │ │ ├── TreeNode.css │ │ │ ├── TreeNode.tsx │ │ │ ├── TreeNodeContent.tsx │ │ │ ├── TreeNodeMenu.tsx │ │ │ ├── TreeNodeSimple.tsx │ │ │ ├── TreeUtils.ts │ │ │ └── useDragAndDrop.ts │ │ └── tags/ │ │ ├── TagControls.tsx │ │ ├── TagFormModal.tsx │ │ ├── TagItem.tsx │ │ ├── TagManageModal.tsx │ │ ├── TagSidebar.tsx │ │ └── useTagFiltering.ts │ ├── hooks/ │ │ ├── buildTabOpenerHtml.ts │ │ ├── index.ts │ │ ├── useAnimatedProgress.ts │ │ ├── useApiKeys.ts │ │ ├── useBatchActions.ts │ │ ├── useBookmarkFilters.ts │ │ ├── useBookmarks.ts │ │ ├── useClientSideFilter.ts │ │ ├── useLanguage.ts │ │ ├── useLocalPreferences.ts │ │ ├── useMediaQuery.ts │ │ ├── usePreferences.ts │ │ ├── useShare.ts │ │ ├── useStorage.ts │ │ ├── useTabGroupActions.ts │ │ ├── useTabGroupItemActions.ts │ │ ├── useTabGroupMenu.ts │ │ ├── useTabGroupsQuery.ts │ │ └── useTags.ts │ ├── i18n/ │ │ ├── index.ts │ │ └── locales/ │ │ ├── en/ │ │ │ ├── auth.json │ │ │ ├── bookmarks.json │ │ │ ├── common.json │ │ │ ├── errors.json │ │ │ ├── import.json │ │ │ ├── info.json │ │ │ ├── settings.json │ │ │ ├── share.json │ │ │ ├── tabGroups.json │ │ │ └── tags.json │ │ └── zh-CN/ │ │ ├── auth.json │ │ ├── bookmarks.json │ │ ├── common.json │ │ ├── errors.json │ │ ├── import.json │ │ ├── info.json │ │ ├── settings.json │ │ ├── share.json │ │ ├── tabGroups.json │ │ └── tags.json │ ├── lib/ │ │ ├── ai/ │ │ │ ├── client.ts │ │ │ ├── constants.ts │ │ │ └── models.ts │ │ ├── api-client.ts │ │ ├── constants/ │ │ │ ├── bookmarks.ts │ │ │ └── z-index.ts │ │ ├── image-utils.ts │ │ ├── logger.ts │ │ ├── query-client.ts │ │ ├── search-utils.ts │ │ ├── types/ │ │ │ ├── api.types.ts │ │ │ ├── auth.types.ts │ │ │ ├── bookmark.types.ts │ │ │ ├── index.ts │ │ │ ├── preferences.types.ts │ │ │ └── tab-group.types.ts │ │ └── types.ts │ ├── main.tsx │ ├── pages/ │ │ ├── about/ │ │ │ └── AboutPage.tsx │ │ ├── auth/ │ │ │ ├── LoginPage.tsx │ │ │ └── RegisterPage.tsx │ │ ├── bookmarks/ │ │ │ ├── BookmarkStatisticsPage.tsx │ │ │ ├── BookmarkTrashPage.tsx │ │ │ ├── BookmarksPage.tsx │ │ │ ├── TrashBookmarkItem.tsx │ │ │ ├── components/ │ │ │ │ ├── BatchSelectionPrompt.tsx │ │ │ │ ├── MobileTagDrawer.tsx │ │ │ │ └── StatisticsCards.tsx │ │ │ └── hooks/ │ │ │ ├── useBookmarksEffects.ts │ │ │ ├── useBookmarksState.ts │ │ │ └── useStatisticsData.ts │ │ ├── extension/ │ │ │ └── ExtensionPage.tsx │ │ ├── info/ │ │ │ ├── AboutPage.tsx │ │ │ ├── HelpPage.tsx │ │ │ ├── PrivacyPage.tsx │ │ │ └── TermsPage.tsx │ │ ├── settings/ │ │ │ ├── ApiKeysPage.tsx │ │ │ ├── GeneralSettingsPage.tsx │ │ │ ├── ImportExportPage.tsx │ │ │ ├── PermissionsPage.tsx │ │ │ └── ShareSettingsPage.tsx │ │ ├── share/ │ │ │ ├── PublicSharePage.tsx │ │ │ ├── components/ │ │ │ │ └── ShareTopBar.tsx │ │ │ └── hooks/ │ │ │ └── usePublicShareState.ts │ │ └── tab-groups/ │ │ ├── StatisticsPage.tsx │ │ ├── TabGroupDetailHeader.tsx │ │ ├── TabGroupDetailPage.tsx │ │ ├── TabGroupEmptyState.tsx │ │ ├── TabGroupItem.tsx │ │ ├── TabGroupsPage.tsx │ │ ├── TodoPage.tsx │ │ ├── TrashPage.tsx │ │ ├── components/ │ │ │ ├── TabGroupsGrid.tsx │ │ │ └── TabGroupsList.tsx │ │ └── hooks/ │ │ ├── useGroupManagement.ts │ │ ├── useTabGroupDetailState.ts │ │ ├── useTabGroupItemDnD.ts │ │ ├── useTabGroupsData.ts │ │ └── useTabGroupsState.ts │ ├── routes/ │ │ └── index.tsx │ ├── services/ │ │ ├── api-keys.ts │ │ ├── auth.ts │ │ ├── bookmarks.ts │ │ ├── index.ts │ │ ├── preferences.ts │ │ ├── share.ts │ │ ├── storage.ts │ │ ├── tab-groups.ts │ │ └── tags.ts │ ├── stores/ │ │ ├── authStore.ts │ │ ├── dialogStore.ts │ │ ├── index.ts │ │ ├── themeStore.ts │ │ └── toastStore.ts │ ├── styles/ │ │ ├── components.css │ │ ├── index.css │ │ └── themes/ │ │ ├── default.css │ │ └── orange.css │ └── vite-env.d.ts ├── tailwind.config.js ├── test-register.js ├── tsconfig.json ├── vite.config.ts └── wrangler.toml.example ================================================ FILE CONTENTS ================================================ ================================================ FILE: DEPLOYMENT.md ================================================ # 🚀 TMarks 部署指南 ## 📹 视频教程 **完整部署教程视频**: [点击观看](https://bushutmarks.pages.dev/course/tmarks) 跟随视频教程,3 分钟完成部署。 --- ## 开源用户一页部署指南 **前置条件** - 有 Cloudflare 账号 - 有 GitHub 账号 --- ### 1. 连接仓库并配置构建 1. 在 GitHub 上 Fork 本仓库 2. 打开 Cloudflare Dashboard → **Workers & Pages** → **Pages** → **创建项目** 3. 选择「连接到 Git」,选中你的 Fork 4. 构建配置: - 根目录:`tmarks` - 构建命令:`pnpm install && pnpm build:deploy` - 构建输出目录:`.deploy` 5. 保存并触发一次部署(第一次失败没关系,后面会修好) ### 2. 创建 Cloudflare 资源 1. **D1 数据库(必需)** - Workers & Pages → **D1 SQL Database** → Create database - 名称:`tmarks-prod-db` 2. **R2 存储桶(可选,快照用)** - R2 对象存储 → 创建存储桶 - 名称:`tmarks-snapshots` - 不创建则快照功能不可用,但其他功能正常 > **注意**:KV 命名空间已不再需要,代码中已移除 KV 依赖 ### 3. 在 Pages 项目中绑定资源 进入 Pages 项目 → **设置 → 函数**: - **D1 绑定(必需)**: - 新建 D1 绑定,变量名:`DB` → 选择 `tmarks-prod-db` - **R2 绑定(可选)**: - 新建 R2 绑定,变量名:`SNAPSHOTS_BUCKET` → 选择 `tmarks-snapshots` > **重要**:如果之前配置过 KV 绑定(变量名 `TMARKS_KV`),请删除该绑定,代码中已不再使用 KV。 > > 没有 R2 时,可以跳过 R2 绑定,应用仍然可以启动(快照功能不可用)。 ### 4. 配置环境变量 进入 Pages 项目 → **设置 → 环境变量(生产环境)**,复制以下配置: ``` ALLOW_REGISTRATION = "true" ENVIRONMENT = "production" JWT_ACCESS_TOKEN_EXPIRES_IN = "365d" JWT_REFRESH_TOKEN_EXPIRES_IN = "365d" R2_MAX_TOTAL_BYTES = "7516192768" JWT_SECRET = "your-long-random-jwt-secret-at-least-48-characters" ENCRYPTION_KEY = "your-long-random-encryption-key-at-least-48-characters" ``` > **重要**:`JWT_SECRET` 和 `ENCRYPTION_KEY` 必须替换为你自己生成的随机字符串(建议 ≥ 48 位) ### 5. 初始化数据库 1. 打开 **Workers & Pages → D1 SQL Database** 2. 进入 `tmarks-prod-db` → **Console** 3. 打开仓库中的以下 SQL 文件: - `tmarks/migrations/0001_d1_console.sql` - `tmarks/migrations/0002_d1_console_ai_settings.sql` - `tmarks/migrations/0100_d1_console.sql` - `tmarks/migrations/0101_d1_console.sql` 4. 复制全部 SQL,粘贴到控制台,点击 **Execute** 执行 ### 6. 重新部署 1. 回到 Pages 项目 → 部署 2. 对之前失败的部署点击「重试」,或推送任意提交重新触发 3. 构建成功后,就可以访问你的 TMarks 站点了 🎉 > 之后更新:只要往 GitHub 推代码,Cloudflare 会自动重新构建和部署,之前配置的数据库 / R2 / 环境变量都不会丢。 --- ## 常见问题 ### 部署失败:KV namespace not found **原因**:Cloudflare Pages Dashboard 中配置了 KV 绑定,但代码中已不再使用 KV。 **解决方案**: 1. 进入 Pages 项目 → 设置 → 函数 2. 找到 KV namespace bindings 3. 删除名为 `TMARKS_KV` 的绑定 4. 保存并重新部署 ### 部署失败:D1 database not found **原因**:D1 数据库绑定配置不正确或数据库不存在。 **解决方案**: 1. 确认已创建 D1 数据库 `tmarks-prod-db` 2. 进入 Pages 项目 → 设置 → 函数 3. 检查 D1 绑定:变量名必须是 `DB`,选择正确的数据库 4. 保存并重新部署 ### 如何更新到最新版本 1. 在 GitHub 上同步你的 Fork(Sync fork 按钮) 2. Cloudflare Pages 会自动检测到更新并重新部署 3. 如果有数据库迁移,需要在 D1 Console 中执行新的 SQL 文件 --- ## 本地开发 ```bash # 1. 克隆项目 git clone https://github.com/ai-tmarks/tmarks.git cd tmarks # 2. 安装依赖 cd tmarks pnpm install # 3. 创建数据库并迁移 wrangler d1 create tmarks-prod-db --local pnpm db:migrate:local # 4. 启动开发服务器 pnpm dev # 访问 http://localhost:5173 ``` --- ## 技术支持 - [问题反馈](https://github.com/ai-tmarks/tmarks/issues) - [功能建议](https://github.com/ai-tmarks/tmarks/discussions) - [视频教程](https://bushutmarks.pages.dev/course/tmarks) ================================================ FILE: LICENSE ================================================ Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0) Copyright (c) 2024 TMarks Team You are free to: Share — copy and redistribute the material in any medium or format Adapt — remix, transform, and build upon the material Under the following terms: Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. NonCommercial — You may not use the material for commercial purposes. No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
# 🔖 TMarks **AI 驱动的智能书签管理系统** [![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue.svg)](https://www.typescriptlang.org/) [![React](https://img.shields.io/badge/React-18.3%20%7C%2019-61dafb.svg)](https://reactjs.org/) [![Vite](https://img.shields.io/badge/Vite-6.0%20%7C%207-646cff.svg)](https://vitejs.dev/) [![Cloudflare](https://img.shields.io/badge/Cloudflare-Workers-f38020.svg)](https://workers.cloudflare.com/) [![许可证](https://img.shields.io/badge/许可证-MIT-green.svg)](LICENSE) 简体中文 [在线演示](https://tmarks.669696.xyz) | [视频教程](https://bushutmarks.pages.dev/course/tmarks) | [问题反馈](https://github.com/ai-tmarks/tmarks/issues) | [功能建议](https://github.com/ai-tmarks/tmarks/discussions)
--- ## ✨ 项目简介 TMarks 是一个现代化的智能书签管理系统,结合 AI 技术自动生成标签,让书签管理变得简单高效。 ### 核心特性 - 📚 **智能书签管理** - AI自动标签、多维筛选、批量操作、拖拽排序 - 🗂️ **标签页组管理** - 一键收纳标签页、智能分组、快速恢复 - 🌐 **公开分享** - 创建个性化书签展示页、KV缓存加速 - 🔌 **浏览器扩展** - 快速保存、AI推荐、离线支持、自动同步 - 🔐 **安全可靠** - JWT认证、API Key管理、数据加密 ### 技术栈 - **前端**: React 18/19 + TypeScript + Vite + TailwindCSS 4 - **后端**: Cloudflare Workers + Pages Functions - **数据库**: Cloudflare D1 (SQLite) - **快照存储**: Cloudflare R2(可选,用于存储网页快照 HTML 与图片,支持全局 7GB 配额限制) - **AI集成**: 支持 OpenAI、Anthropic、DeepSeek、智谱等 8+ 提供商 --- ## 🔌 浏览器扩展 登录 TMarks 后,进入 **个人设置** 页面下载并安装浏览器扩展。 ### 扩展功能 - **快速保存书签** - 一键保存当前网页,AI 自动推荐标签 - **标签页收纳** - 一键收纳所有标签页,改天再看 ### 支持浏览器 Chrome / Edge / Opera / Brave / 360 / QQ / 搜狗 --- ## 🚀 部署 📖 **详细部署文档**: [DEPLOYMENT.md](DEPLOYMENT.md) 📹 **视频教程**: [点击观看](https://bushutmarks.pages.dev/course/tmarks)(3 分钟完成部署) --- ## 📄 许可证 本项目采用 [MIT License](LICENSE) 开源协议。 ================================================ FILE: tmarks/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? # Environment .env .env.local .env.*.local .dev.vars # Wrangler .wrangler .mf # Testing coverage # Build build .deploy # Database migration history (local only) .migration-history.json .migration-history-prod.json ================================================ FILE: tmarks/.prettierignore ================================================ # Dependencies node_modules # Build outputs dist dist-ssr build .wrangler # Environment .env .env.local .dev.vars # Logs *.log # OS .DS_Store # IDE .vscode .idea ================================================ FILE: tmarks/.prettierrc ================================================ { "semi": false, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "printWidth": 100, "arrowParens": "always" } ================================================ FILE: tmarks/API-DATABASE-AUDIT.md ================================================ # 数据库表与 API 接口一致性审计报告 生成时间: 2026-04-15 ## 📊 总体统计 - **总 API 端点数**: 73 - **总数据库表数**: 21 - **已使用的表数**: 17 (81.0%) - **未使用的表数**: 4 (19.0%) - **中间件/工具使用**: 3 (rate_limits, api_key_rate_limits, bookmark_images) --- ## ✅ 核心功能表使用情况 ### 1. 书签相关 (Bookmarks) | 表名 | 使用端点数 | 状态 | |------|-----------|------| | `bookmarks` | 37 | ✅ 核心表 | | `bookmark_tags` | 24 | ✅ 活跃 | | `bookmark_snapshots` | 16 | ✅ 活跃 | | `bookmark_click_events` | 3 | ✅ 正常 | | `bookmark_images` | 0 (工具函数使用) | ✅ 正常 | **分析**: - 书签核心功能完整,包括标签、快照、点击统计 - `bookmark_images` 通过 `lib/image-upload.ts` 和 `lib/storage-quota.ts` 使用,用于封面图管理 ### 2. 标签组相关 (Tab Groups) | 表名 | 使用端点数 | 状态 | |------|-----------|------| | `tab_groups` | 22 | ✅ 核心表 | | `tab_group_items` | 17 | ✅ 活跃 | | `shares` | 4 | ✅ 正常 | **分析**: 标签组功能完整,包括分享功能 ### 3. 标签相关 (Tags) | 表名 | 使用端点数 | 状态 | |------|-----------|------| | `tags` | 21 | ✅ 核心表 | **分析**: 标签功能活跃,与书签紧密集成 ### 4. 用户与认证 | 表名 | 使用端点数 | 状态 | |------|-----------|------| | `users` | 8 | ✅ 核心表 | | `auth_tokens` | 3 | ✅ 正常 | | `user_preferences` | 5 | ✅ 正常 | **分析**: 用户认证和偏好设置功能正常 ### 5. API 密钥管理 | 表名 | 使用端点数 | 状态 | |------|-----------|------| | `api_keys` | 2 | ✅ 正常 | | `api_key_logs` | 1 | ✅ 正常 | | `api_key_rate_limits` | 0 (中间件使用) | ✅ 正常 | **分析**: - API 密钥基础功能正常 - `api_key_rate_limits` 在中间件 `rate-limiter.ts` 中使用,用于 API Key 速率限制 ### 6. 审计与统计 | 表名 | 使用端点数 | 状态 | |------|-----------|------| | `audit_logs` | 6 | ✅ 正常 | | `statistics` | 0 | ⚠️ 未使用 | **分析**: - 审计日志功能正常 - `statistics` 表未使用,统计可能通过实时查询实现 --- ## ⚠️ 未使用的数据库表 以下表在数据库中定义,但未被任何 API 端点直接使用: ### 1. `bookmark_images` ✅ 工具函数使用 - **定义位置**: `0001_d1_console.sql` - **用途**: 存储书签封面图片(去重存储) - **实际使用**: - `lib/image-upload.ts` - 图片上传和管理 - `lib/storage-quota.ts` - 存储配额计算 - **状态**: ✅ 正常使用,通过工具函数间接调用 ### 2. `statistics` - **定义位置**: `0001_d1_console.sql` - **用途**: 存储每日统计数据 - **建议**: - 检查是否有计划使用此表 - 当前统计通过实时查询实现,考虑是否需要持久化 ### 3. `registration_limits` - **定义位置**: `0001_d1_console.sql` - **用途**: 限制每日注册数量 - **建议**: - 检查注册限流逻辑是否在其他地方实现 - 如果功能已实现,添加对应的 API 使用 ### 4. `ai_settings` - **定义位置**: `0002_d1_console_ai_settings.sql` - **用途**: AI 功能配置 - **建议**: - 这是新功能表,可能尚未实现 API - 需要添加 AI 设置相关的 API 端点 ### 5. `api_key_rate_limits` ✅ 中间件使用 - **定义位置**: `0103_api_key_rate_limits.sql` - **用途**: API 密钥速率限制 - **实际使用**: `lib/api-key/rate-limiter.ts` - API Key 速率限制中间件 - **状态**: ✅ 正常使用,在中间件层面使用 ### 6. `rate_limits` ✅ 中间件使用 - **定义位置**: `0104_rate_limits.sql` - **用途**: 通用速率限制 - **实际使用**: `lib/rate-limit.ts` - 通用速率限制工具 - **状态**: ✅ 正常使用,在中间件层面使用 ### 7. `schema_migrations` - **定义位置**: `0001_d1_console.sql` - **用途**: 数据库迁移版本管理 - **状态**: ✅ 系统表,正常未使用 --- ## 📍 API 端点分类 ### /api/tab/* (扩展 API - 需要 API Key) - 共 34 个端点 - 主要功能:书签、标签组、标签、搜索、统计 - 认证方式:API Key (X-API-Key header) ### /api/v1/* (Web API - 需要 JWT) - 共 35 个端点 - 主要功能:认证、书签、标签组、标签、设置、统计 - 认证方式:JWT Token ### /api/public/* (公开 API) - 1 个端点:公开分享页面 - 无需认证 ### /api/share/* (分享 API) - 1 个端点:标签组分享 - 通过 token 访问 ### /api/snapshot-images/* (快照图片) - 1 个端点:获取快照图片 - 需要签名验证 --- ## 🔍 潜在问题与建议 ### 1. 真正未使用的表 **需要确认的表**: - `ai_settings`: 需要实现 AI 设置 API(新功能) - `statistics`: 评估是否需要持久化统计数据 - `registration_limits`: 确认注册限流策略 ### 2. API 一致性 **建议**: - `/api/tab/*` 和 `/api/v1/*` 存在功能重复 - 考虑统一 API 设计,减少维护成本 - 明确两套 API 的使用场景和差异 ### 3. 缺失的功能 **可能需要添加的 API**: - AI 设置管理 (`/api/v1/settings/ai`) - 注册限流管理 (管理员功能) - 速率限制查询 API(如果需要对外暴露) ### 4. 数据完整性 **建议检查**: - 所有外键约束是否正确设置 - 级联删除是否符合业务逻辑 - 索引是否覆盖常用查询 --- ## 📋 详细端点列表 ### 书签管理 (Bookmarks) #### /api/tab/bookmarks - `GET` - 获取书签列表 - `POST` - 创建书签 - 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_snapshots` #### /api/tab/bookmarks/:id - `GET` - 获取书签详情 - `PATCH` - 更新书签 - `DELETE` - 软删除书签 - 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_snapshots` #### /api/tab/bookmarks/:id/click - `POST` - 记录书签点击 - 使用表: `bookmarks`, `bookmark_click_events` #### /api/tab/bookmarks/:id/snapshots - `GET` - 获取快照列表 - `POST` - 创建快照 - 使用表: `bookmarks`, `bookmark_snapshots`, `user_preferences` #### /api/tab/bookmarks/batch - `POST` - 批量创建书签 - 使用表: `bookmarks`, `tags`, `bookmark_tags`, `audit_logs` #### /api/tab/bookmarks/trash - `GET` - 获取回收站书签 - 使用表: `bookmarks`, `tags`, `bookmark_tags` #### /api/tab/bookmarks/trash/empty - `DELETE` - 清空回收站 - 使用表: `bookmarks`, `bookmark_tags`, `bookmark_snapshots` ### 标签组管理 (Tab Groups) #### /api/tab/tab-groups - `GET` - 获取标签组列表 - `POST` - 创建标签组 - 使用表: `tab_groups`, `tab_group_items` #### /api/tab/tab-groups/:id - `GET` - 获取标签组详情 - `PATCH` - 更新标签组 - `DELETE` - 删除标签组 - 使用表: `tab_groups`, `tab_group_items` #### /api/tab/tab-groups/:id/share - `POST` - 创建分享 - `DELETE` - 删除分享 - 使用表: `tab_groups`, `shares` ### 标签管理 (Tags) #### /api/tab/tags - `GET` - 获取标签列表 - `POST` - 创建标签 - 使用表: `tags`, `bookmark_tags` #### /api/tab/tags/:id - `GET` - 获取标签详情 - `PATCH` - 更新标签 - `DELETE` - 删除标签 - 使用表: `tags`, `bookmark_tags` ### 认证 (Authentication) #### /api/v1/auth/register - `POST` - 用户注册 - 使用表: `users`, `user_preferences`, `audit_logs` #### /api/v1/auth/login - `POST` - 用户登录 - 使用表: `users`, `auth_tokens`, `audit_logs` #### /api/v1/auth/logout - `POST` - 用户登出 - 使用表: `auth_tokens`, `audit_logs` #### /api/v1/auth/refresh - `POST` - 刷新 Token - 使用表: `users`, `auth_tokens`, `audit_logs` ### 设置 (Settings) #### /api/v1/preferences - `GET` - 获取用户偏好 - `PUT` - 更新用户偏好 - 使用表: `user_preferences` #### /api/v1/settings/api-keys - `GET` - 获取 API 密钥列表 - `POST` - 创建 API 密钥 - 使用表: `api_keys` #### /api/v1/settings/api-keys/:id - `GET` - 获取 API 密钥详情 - `PATCH` - 更新 API 密钥 - `DELETE` - 删除 API 密钥 - 使用表: `api_keys`, `api_key_logs` #### /api/v1/settings/share - `GET` - 获取公开分享设置 - `PUT` - 更新公开分享设置 - 使用表: `users` ### 统计 (Statistics) #### /api/tab/statistics - `GET` - 获取统计数据 - 使用表: `tab_groups`, `tab_group_items`, `shares` #### /api/v1/statistics - `GET` - 获取统计数据 - 使用表: `tab_groups`, `tab_group_items` #### /api/v1/bookmarks/statistics - `GET` - 获取书签统计 - 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_click_events` --- ## 🎯 行动建议 ### 立即执行 1. ✅ 修复导入路径问题(已完成) 2. ✅ 确认速率限制功能的实现位置(已确认在中间件中) 3. ✅ 确认 bookmark_images 功能(已确认在工具函数中) ### 短期计划 1. 实现 AI 设置相关 API 2. 评估 `statistics` 表的使用需求 3. 确认 `registration_limits` 的实现方式 ### 长期规划 1. 统一 `/api/tab/*` 和 `/api/v1/*` 的设计 2. 完善 API 文档 3. 添加更多的统计和分析功能 --- ## 📝 备注 - 本报告基于代码静态分析生成 - 未包含中间件和工具函数中的数据库操作 - 建议定期更新此报告以保持同步 ================================================ FILE: tmarks/eslint.config.js ================================================ import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' export default tseslint.config( { ignores: [ 'node_modules', 'dist', 'dist-ssr', 'build', '.wrangler', '.deploy', '**/*.config.js', '**/*.config.ts', 'vite.config.ts', ], }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, ) ================================================ FILE: tmarks/functions/api/_middleware.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import { corsHeaders, securityHeaders, requestLogger } from '../middleware/security' export const onRequest: PagesFunction[] = [ requestLogger, corsHeaders, securityHeaders, ] ================================================ FILE: tmarks/functions/api/index.ts ================================================ /** * API - API * : /api * */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../lib/types' export const onRequestGet: PagesFunction = async () => { return Response.json({ name: 'TMarks API', version: 'v1', description: 'TMarks Bookmark Management API', documentation: '/api/docs', endpoints: { bookmarks: { list: 'GET /api/bookmarks', create: 'POST /api/bookmarks', get: 'GET /api/bookmarks/:id', update: 'PATCH /api/bookmarks/:id', delete: 'DELETE /api/bookmarks/:id', }, tags: { list: 'GET /api/tags', create: 'POST /api/tags', get: 'GET /api/tags/:id', update: 'PATCH /api/tags/:id', delete: 'DELETE /api/tags/:id', }, user: { me: 'GET /api/me', }, search: { global: 'GET /api/search?q=keyword', }, }, authentication: { type: 'API Key', header: 'X-API-Key', format: 'tmk_live_xxxxxxxxxxxxxxxxxxxx', how_to_get: 'Create an API Key in TMarks Settings > API Keys', }, rate_limits: { per_minute: 60, per_hour: 1000, per_day: 10000, }, support: { docs: '/help', }, }) } ================================================ FILE: tmarks/functions/api/public/[slug].ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow, PublicProfile } from '../../lib/types' import { notFound, success, internalError } from '../../lib/response' import { normalizeBookmark } from '../../lib/bookmark-utils' import { CacheService } from '../../lib/cache' import { generateCacheKey } from '../../lib/cache/strategies' interface PublicSharePayload { profile: { username: string title: string | null description: string | null slug: string } bookmarks: Array & { tags: Array<{ id: string; name: string; color: string | null }> }> tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }> generated_at: string } interface PublicSharePaginatedPayload { profile: { username: string title: string | null description: string | null slug: string } bookmarks: Array & { tags: Array<{ id: string; name: string; color: string | null }> }> tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }> meta: { page_size: number count: number next_cursor: string | null has_more: boolean } } export const onRequestGet: PagesFunction = async (context) => { const slug = (context.params.slug as string | undefined)?.toLowerCase() if (!slug) { return notFound('Share link not found') } const url = new URL(context.request.url) const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100) const pageCursor = url.searchParams.get('page_cursor') || '' // ,() const usePagination = url.searchParams.has('page_size') || url.searchParams.has('page_cursor') // const cache = new CacheService(context.env) const cacheKey = usePagination ? generateCacheKey('publicShare', slug, { page_cursor: pageCursor || 'first', page_size: pageSize }) : generateCacheKey('publicShare', slug) try { // const user = await context.env.DB.prepare( `SELECT id as user_id, username, public_share_enabled, public_slug, public_page_title, public_page_description FROM users WHERE LOWER(public_slug) = ? AND public_share_enabled = 1` ) .bind(slug) .first() if (!user || !user.public_share_enabled || !user.public_slug) { return notFound('Share link not found') } // const cached = await cache.get('publicShare', cacheKey) if (cached) { return success({ ...cached, _cached: true, // }) } // let bookmarkQuery = ` SELECT * FROM bookmarks WHERE user_id = ? AND is_public = 1 AND deleted_at IS NULL ` const bookmarkParams: (string | number)[] = [user.user_id] // if (usePagination && pageCursor) { bookmarkQuery += ' AND id < ?' bookmarkParams.push(pageCursor) } bookmarkQuery += ' ORDER BY is_pinned DESC, created_at DESC' // , LIMIT if (usePagination) { bookmarkQuery += ' LIMIT ?' bookmarkParams.push(pageSize + 1) // } const { results: bookmarkRows } = await context.env.DB.prepare(bookmarkQuery) .bind(...bookmarkParams) .all() // () const hasMore = usePagination && bookmarkRows.length > pageSize const bookmarksToProcess = usePagination && hasMore ? bookmarkRows.slice(0, pageSize) : bookmarkRows const nextCursor = usePagination && hasMore && bookmarksToProcess.length > 0 ? String(bookmarksToProcess[bookmarksToProcess.length - 1].id) : null const bookmarkIds = bookmarksToProcess.map((row) => row.id) let allTags: Array<{ bookmark_id: string; id: string; name: string; color: string | null }> = [] if (bookmarkIds.length > 0) { const placeholders = bookmarkIds.map(() => '?').join(',') const { results: tagRows } = await context.env.DB.prepare( `SELECT bt.bookmark_id, t.id, t.name, t.color FROM bookmark_tags bt INNER JOIN tags t ON t.id = bt.tag_id WHERE bt.bookmark_id IN (${placeholders}) AND t.deleted_at IS NULL ORDER BY t.name` ) .bind(...bookmarkIds) .all<{ bookmark_id: string; id: string; name: string; color: string | null }>() allTags = tagRows || [] } const tagsByBookmark = new Map>() for (const tag of allTags) { if (!tagsByBookmark.has(tag.bookmark_id)) { tagsByBookmark.set(tag.bookmark_id, []) } const tags = tagsByBookmark.get(tag.bookmark_id) if (tags) { tags.push({ id: tag.id, name: tag.name, color: tag.color }) } } const bookmarks = bookmarksToProcess.map((row) => ({ ...normalizeBookmark(row), tags: tagsByBookmark.get(row.id) || [], })) // () let tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }> = [] if (!usePagination || !pageCursor) { const tagsCacheKey = generateCacheKey('publicShare', `${slug}:tags`) // const cachedTags = await cache.get('publicShare', tagsCacheKey) if (cachedTags) { tags = cachedTags } else { // const { results: tagStats } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color, COUNT(DISTINCT bt.bookmark_id) as bookmark_count FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id INNER JOIN bookmarks b ON bt.bookmark_id = b.id WHERE b.user_id = ? AND b.is_public = 1 AND b.deleted_at IS NULL AND t.deleted_at IS NULL GROUP BY t.id, t.name, t.color ORDER BY t.name` ) .bind(user.user_id) .all<{ id: string; name: string; color: string | null; bookmark_count: number }>() tags = tagStats || [] // (30) if (tags.length > 0) { await cache.set('publicShare', tagsCacheKey, tags, { async: true }) } } } // if (usePagination) { const paginatedPayload: PublicSharePaginatedPayload = { profile: { username: user.username, title: user.public_page_title, description: user.public_page_description, slug: user.public_slug, }, bookmarks, tags, meta: { page_size: pageSize, count: bookmarks.length, next_cursor: nextCursor, has_more: hasMore, }, } // (30 TTL, Level 0 ) await cache.set('publicShare', cacheKey, paginatedPayload, { async: true }) return success(paginatedPayload) } else { const payload: PublicSharePayload = { profile: { username: user.username, title: user.public_page_title, description: user.public_page_description, slug: user.public_slug, }, bookmarks, tags, generated_at: new Date().toISOString(), } // (30 TTL, Level 0 ) await cache.set('publicShare', cacheKey, payload, { async: true }) return success(payload) } } catch (error) { console.error('Public share error:', error) return internalError('Failed to load shared bookmarks') } } ================================================ FILE: tmarks/functions/api/share/[token].ts ================================================ /** * API * : /api/share/:token * : () */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../lib/types' import { success, notFound, internalError } from '../../lib/response' interface ShareRow { id: string group_id: string user_id: string share_token: string is_public: number view_count: number created_at: string expires_at: string | null } interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null created_at: string updated_at: string } interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number is_pinned: number is_todo: number created_at: string } // GET /api/share/:token - export const onRequestGet: PagesFunction = async (context) => { const shareToken = context.params.token try { // Get share const share = await context.env.DB.prepare( 'SELECT * FROM shares WHERE share_token = ?' ) .bind(shareToken) .first() if (!share) { return notFound('Share not found') } // Check if share is public if (share.is_public !== 1) { return notFound('Share is private') } // Check if share has expired if (share.expires_at) { const expiresAt = new Date(share.expires_at) if (expiresAt < new Date()) { return notFound('Share has expired') } } // Get tab group const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND is_deleted = 0' ) .bind(share.group_id) .first() if (!groupRow) { return notFound('Tab group not found') } // Get tab group items const { results: items } = await context.env.DB.prepare( 'SELECT * FROM tab_group_items WHERE group_id = ? ORDER BY position ASC' ) .bind(share.group_id) .all() // Parse tags let tags: string[] | null = null if (groupRow.tags) { try { tags = JSON.parse(groupRow.tags) } catch { tags = null } } // Increment view count await context.env.DB.prepare( 'UPDATE shares SET view_count = view_count + 1 WHERE id = ?' ) .bind(share.id) .run() return success({ tab_group: { ...groupRow, tags, items: items || [], item_count: items?.length || 0, }, share_info: { view_count: share.view_count + 1, created_at: share.created_at, expires_at: share.expires_at, }, }) } catch (error) { console.error('Get shared tab group error:', error) return internalError('Failed to get shared tab group') } } ================================================ FILE: tmarks/functions/api/shared/cache.ts ================================================ import type { Env } from '../../lib/types' import { CacheService } from '../../lib/cache' import { getCacheInvalidationPrefix } from '../../lib/cache/strategies' /** * */ export async function invalidatePublicShareCache(env: Env, userId: string) { // slug const record = await env.DB.prepare( `SELECT public_slug FROM users WHERE id = ? AND public_share_enabled = 1` ) .bind(userId) .first<{ public_slug: string | null }>() if (!record?.public_slug) { return } // CacheService const cache = new CacheService(env) const prefix = getCacheInvalidationPrefix(record.public_slug.toLowerCase(), 'publicShare') await cache.invalidate(prefix) } ================================================ FILE: tmarks/functions/api/snapshot-images/[hash].ts ================================================ /** * API * : /api/snapshot-images/:hash * R2 * * : API */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../lib/types' import { notFound, internalError } from '../../lib/response' import { generateImageSig } from '../../lib/image-sig' // OPTIONS /api/snapshot-images/:hash - CORS export const onRequestOptions: PagesFunction = async () => { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '86400', }, }) } // GET /api/snapshot-images/:hash - export const onRequestGet: PagesFunction = async (context) => { try { const hash = context.params.hash as string const bucket = context.env.SNAPSHOTS_BUCKET const db = context.env.DB if (!bucket || !db) { return internalError('Storage not configured') } // URL const url = new URL(context.request.url) const userId = url.searchParams.get('u') const bookmarkId = url.searchParams.get('b') const version = url.searchParams.get('v') console.log(`[Snapshot Image API] Request: hash=${hash}, u=${userId}, b=${bookmarkId}, v=${version}`) if (!userId || !bookmarkId || !version) { console.warn(`[Snapshot Image API] Missing parameters: u=${userId}, b=${bookmarkId}, v=${version}`) return new Response('Missing required parameters', { status: 400, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*', }, }) } // // const snapshot = await db .prepare( `SELECT s.id FROM bookmark_snapshots s JOIN bookmarks b ON s.bookmark_id = b.id WHERE s.bookmark_id = ? AND s.user_id = ? AND s.version = ? AND b.deleted_at IS NULL` ) .bind(bookmarkId, userId, parseInt(version, 10) || 0) .first() if (!snapshot) { console.warn(`[Snapshot Image API] Snapshot not found or access denied: u=${userId}, b=${bookmarkId}, v=${version}, hash=${hash}`) return notFound('Snapshot not found or access denied') } // const sig = url.searchParams.get('sig') if (!sig) { return new Response('Missing image signature', { status: 403, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*', }, }) } const expectedSig = await generateImageSig(hash, userId, bookmarkId, context.env.JWT_SECRET) if (sig !== expectedSig) { return new Response('Invalid image signature', { status: 403, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*', }, }) } // R2 let imageKey = `${userId}/${bookmarkId}/v${version}/images/${hash}` console.log(`[Snapshot Image API] Fetching: ${imageKey}`) // R2 let r2Object = await bucket.get(imageKey) // ,(/) if (!r2Object) { console.log(`[Snapshot Image API] Not found, trying alternative formats...`) // hash , if (hash.includes('.')) { const hashWithoutExt = hash.replace(/\.(webp|jpg|jpeg|png|gif)$/i, '') const altKey = `${userId}/${bookmarkId}/v${version}/images/${hashWithoutExt}` console.log(`[Snapshot Image API] Trying without extension: ${altKey}`) r2Object = await bucket.get(altKey) if (r2Object) imageKey = altKey } else { // hash , const extensions = ['.webp', '.jpg', '.jpeg', '.png', '.gif'] for (const ext of extensions) { const altKey = `${userId}/${bookmarkId}/v${version}/images/${hash}${ext}` console.log(`[Snapshot Image API] Trying with extension: ${altKey}`) r2Object = await bucket.get(altKey) if (r2Object) { imageKey = altKey break } } } } if (!r2Object) { console.warn(`[Snapshot Image API] Image not found in R2 (tried all formats): ${hash}`) return new Response('Image not found', { status: 404, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*', }, }) } // const imageData = await r2Object.arrayBuffer() const contentType = r2Object.httpMetadata?.contentType || 'image/jpeg' console.log(`[Snapshot Image API] Serving: ${imageKey}, ${(imageData.byteLength / 1024).toFixed(1)}KB, type: ${contentType}`) return new Response(imageData, { headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', // 'Access-Control-Allow-Origin': '*', // () 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, }) } catch (error) { console.error('[Snapshot Image API] Error:', error) // , return new Response('Failed to load image', { status: 500, headers: { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*', }, }) } } ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/click.ts ================================================ /** * API - * : /api/tab/bookmarks/:id/click * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' // POST /api/tab/bookmarks/:id/click - export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('bookmarks.update'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const db = context.env.DB const now = new Date().toISOString() // const bookmark = await db.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // , await db.batch([ db.prepare( 'UPDATE bookmarks SET click_count = click_count + 1, last_clicked_at = ? WHERE id = ?' ).bind(now, bookmarkId), db.prepare( 'INSERT INTO bookmark_click_events (bookmark_id, user_id, clicked_at) VALUES (?, ?, ?)' ).bind(bookmarkId, userId, now), ]) return success({ message: 'Click recorded successfully', clicked_at: now, }) } catch (error) { console.error('Record bookmark click error:', error) return internalError('Failed to record click') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/permanent.ts ================================================ /** * API - * : /api/tab/bookmarks/:id/permanent * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { noContent, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' // DELETE /api/tab/bookmarks/:id/permanent - () export const onRequestDelete: PagesFunction[] = [ requireApiKeyAuth('bookmarks.delete'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { // const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found in trash') } // await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?') .bind(bookmarkId) .run() // await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?') .bind(bookmarkId) .run() // await context.env.DB.prepare('DELETE FROM bookmarks WHERE id = ?') .bind(bookmarkId) .run() return noContent() } catch (error) { console.error('Permanent delete bookmark error:', error) return internalError('Failed to permanently delete bookmark') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/restore.ts ================================================ /** * API - * : /api/tab/bookmarks/:id/restore * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' import { normalizeBookmark } from '../../../../lib/bookmark-utils' // PATCH /api/tab/bookmarks/:id/restore - export const onRequestPatch: PagesFunction[] = [ requireApiKeyAuth('bookmarks.update'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { // const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found in trash') } const now = new Date().toISOString() // : deleted_at await context.env.DB.prepare( 'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ?' ) .bind(now, bookmarkId) .run() // const bookmarkRow = await context.env.DB.prepare( 'SELECT * FROM bookmarks WHERE id = ?' ) .bind(bookmarkId) .first() if (!bookmarkRow) { return internalError('Failed to load bookmark after restore') } // const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL` ) .bind(bookmarkId) .all<{ id: string; name: string; color: string | null }>() return success({ bookmark: { ...normalizeBookmark(bookmarkRow), tags: tags || [], }, }) } catch (error) { console.error('Restore bookmark error:', error) return internalError('Failed to restore bookmark') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshot-upload.ts ================================================ /** * * Base64 R2 */ import type { R2Bucket } from '@cloudflare/workers-types' const R2_UPLOAD_CONCURRENCY = 6 interface ImageInput { hash: string data: string // base64 type: string } interface DecodedImage { hash: string bytes: Uint8Array type: string } /** Base64 , charCodeAt */ export function decodeBase64Image(image: ImageInput): DecodedImage | null { try { const base64Data = image.data.includes(',') ? image.data.split(',')[1] : image.data const binaryString = atob(base64Data) const bytes = new Uint8Array(binaryString.length) for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i) } return { hash: image.hash, bytes, type: image.type } } catch { return null } } interface UploadResult { uploadedHashes: string[] totalImageSize: number } /** * R2 * N R2_UPLOAD_CONCURRENCY */ export async function uploadImagesConcurrently( decoded: DecodedImage[], bucket: R2Bucket, userId: string, bookmarkId: string, version: number, timestamp: number, ): Promise { const uploadedHashes: string[] = [] let totalImageSize = 0 for (let i = 0; i < decoded.length; i += R2_UPLOAD_CONCURRENCY) { const batch = decoded.slice(i, i + R2_UPLOAD_CONCURRENCY) const results = await Promise.allSettled( batch.map(async (img) => { const imageKey = `${userId}/${bookmarkId}/v${version}/images/${img.hash}` await bucket.put(imageKey, img.bytes, { httpMetadata: { contentType: img.type }, customMetadata: { userId, bookmarkId, version: version.toString(), snapshotTimestamp: timestamp.toString(), }, }) return img }) ) for (const result of results) { if (result.status === 'fulfilled') { uploadedHashes.push(result.value.hash) totalImageSize += result.value.bytes.length } else { console.error('[Snapshot Upload] Image upload failed:', result.reason) } } } return { uploadedHashes, totalImageSize } } /** * HTML URL * , O(n*m) replace */ export function replaceImagePlaceholders( html: string, uploadedHashes: string[], userId: string, bookmarkId: string, version: number, ): string { if (uploadedHashes.length === 0) return html const hashSet = new Set(uploadedHashes) // Match all /api/snapshot-images/{hash} placeholders in one pass return html.replace( /\/api\/snapshot-images\/([a-f0-9]+)/g, (match, hash) => { if (hashSet.has(hash)) { return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}` } return match } ) } ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots/[snapshotId].ts ================================================ /** * API * : /api/tab/bookmarks/:id/snapshots/:snapshotId * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../../lib/types' import { success, notFound, internalError } from '../../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../../middleware/api-key-auth-pages' // GET /api/tab/bookmarks/:id/snapshots/:snapshotId - export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('bookmarks.read'), async (context) => { try { const userId = context.data.user_id const bookmarkId = context.params.id as string const snapshotId = context.params.snapshotId as string const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const snapshot = await db .prepare( `SELECT s.*, b.url as bookmark_url FROM bookmark_snapshots s JOIN bookmarks b ON s.bookmark_id = b.id WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?` ) .bind(snapshotId, bookmarkId, userId) .first() if (!snapshot) { return notFound('Snapshot not found') } // R2 const r2Object = await bucket.get(snapshot.r2_key as string) if (!r2Object) { return notFound('Snapshot file not found') } // HTML let htmlContent = await r2Object.text() // data URL () const dataUrlCount = (htmlContent.match(/src="data:/g) || []).length const htmlSize = new Blob([htmlContent]).size console.log(`[Snapshot API] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`) // CSP meta HTML head () const cspMetaTag = ''; if (htmlContent.includes('')) { htmlContent = htmlContent.replace('', `${cspMetaTag}`); console.log(`[Snapshot API] Injected CSP meta tag`); } else if (htmlContent.includes('')) { htmlContent = htmlContent.replace('', `${cspMetaTag}`); console.log(`[Snapshot API] Injected CSP meta tag`); } // V2 ( /api/snapshot-images/ ) const isV2 = htmlContent.includes('/api/snapshot-images/') if (isV2) { const version = (snapshot as Record).version as number || 1 // URL: URL, let replacedCount = 0 htmlContent = htmlContent.replace( /\/api\/snapshot-images\/([a-zA-Z0-9._-]+?)(?:\?[^"\s)]*)?(?=["\s)]|$)/g, (_match: string, hash: string) => { replacedCount++ // ,() return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`; } ) console.log(`[Snapshot API] V2 format detected, normalized ${replacedCount} image URLs`) } return new Response(htmlContent, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600', 'X-Content-Type-Options': 'nosniff', // CSP () 'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; img-src * data: blob:; font-src * data:; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'; frame-src *; connect-src *;", }, }) } catch (error) { console.error('Get snapshot error:', error) return internalError('Failed to get snapshot') } }, ] // DELETE /api/tab/bookmarks/:id/snapshots/:snapshotId - export const onRequestDelete: PagesFunction[] = [ requireApiKeyAuth('bookmarks.delete'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id as string const snapshotId = context.params.snapshotId as string try { const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const snapshot = await db .prepare( `SELECT id, r2_key, is_latest FROM bookmark_snapshots WHERE id = ? AND bookmark_id = ? AND user_id = ?` ) .bind(snapshotId, bookmarkId, userId) .first() if (!snapshot) { return notFound('Snapshot not found') } // R2 await bucket.delete(snapshot.r2_key as string) // await db .prepare('DELETE FROM bookmark_snapshots WHERE id = ?') .bind(snapshotId) .run() // (1) await db .prepare( `UPDATE bookmarks SET snapshot_count = MAX(0, snapshot_count - 1) WHERE id = ?` ) .bind(bookmarkId) .run() // , if (snapshot.is_latest) { const nextLatest = await db .prepare( `SELECT id FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC LIMIT 1` ) .bind(bookmarkId, userId) .first() if (nextLatest) { await db .prepare( `UPDATE bookmark_snapshots SET is_latest = 1 WHERE id = ?` ) .bind(nextLatest.id) .run() } else { // , await db .prepare( `UPDATE bookmarks SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0 WHERE id = ?` ) .bind(bookmarkId) .run() } } return success({ message: 'Snapshot deleted successfully' }) } catch (error) { console.error('Delete snapshot error:', error) return internalError('Failed to delete snapshot') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots/cleanup.ts ================================================ /** * API * : /api/v1/bookmarks/:id/snapshots/cleanup * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../../lib/response' import { requireAuth, AuthContext } from '../../../../../middleware/auth' interface CleanupRequest { keep_count?: number older_than_days?: number verify_and_fix?: boolean } interface RouteParams { id: string } interface SnapshotRow { id: string r2_key: string file_size: number } async function deleteSnapshotRows( db: D1Database, ids: string[], userId: string, ): Promise { const placeholders = ids.map(() => '?').join(',') await db .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`) .bind(...ids, userId) .run() } async function updateBookmarkSnapshotCount( db: D1Database, bookmarkId: string, userId: string, ): Promise { const remaining = await db .prepare( `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`, ) .bind(bookmarkId, userId) .first() const remainingCount = (remaining?.count as number) || 0 if (remainingCount === 0) { await db .prepare( `UPDATE bookmarks SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0 WHERE id = ? AND user_id = ?`, ) .bind(bookmarkId, userId) .run() } else { await db .prepare(`UPDATE bookmarks SET snapshot_count = ? WHERE id = ? AND user_id = ?`) .bind(remainingCount, bookmarkId, userId) .run() } } async function handleVerifyAndFix( db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string, ) { const { results: allSnapshots } = await db .prepare( `SELECT id, r2_key, file_size FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`, ) .bind(bookmarkId, userId) .all() const orphaned: SnapshotRow[] = [] for (const snapshot of allSnapshots || []) { try { const r2Object = await bucket.head(snapshot.r2_key) if (!r2Object) orphaned.push(snapshot) } catch { orphaned.push(snapshot) } } if (orphaned.length === 0) { return success({ deleted_count: 0, freed_space: 0, message: 'All snapshots are valid, no orphaned records found', }) } await deleteSnapshotRows(db, orphaned.map((s) => s.id), userId) await updateBookmarkSnapshotCount(db, bookmarkId, userId) return success({ deleted_count: orphaned.length, freed_space: 0, message: `Fixed ${orphaned.length} orphaned snapshot records`, }) } // POST /api/v1/bookmarks/:id/snapshots/cleanup export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const body = await context.request.json() as CleanupRequest const { keep_count, older_than_days, verify_and_fix } = body const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) return notFound('Bookmark not found') if (verify_and_fix) { return handleVerifyAndFix(db, bucket, bookmarkId, userId) } let toDelete: SnapshotRow[] = [] if (keep_count !== undefined && keep_count >= 0) { const result = await db .prepare( `SELECT id, r2_key, file_size FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC LIMIT -1 OFFSET ?`, ) .bind(bookmarkId, userId, keep_count) .all() toDelete = result.results || [] } else if (older_than_days !== undefined && older_than_days > 0) { const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - older_than_days) const result = await db .prepare( `SELECT id, r2_key, file_size FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? AND created_at < ? ORDER BY version ASC`, ) .bind(bookmarkId, userId, cutoffDate.toISOString()) .all() toDelete = result.results || [] } else { return badRequest('Must specify keep_count or older_than_days') } if (toDelete.length === 0) { return success({ deleted_count: 0, freed_space: 0, message: 'No snapshots to delete' }) } const freedSpace = toDelete.reduce((sum, s) => sum + (s.file_size as number || 0), 0) for (const snapshot of toDelete) { await bucket.delete(snapshot.r2_key as string) } await deleteSnapshotRows(db, toDelete.map((s) => s.id), userId) await updateBookmarkSnapshotCount(db, bookmarkId, userId) return success({ deleted_count: toDelete.length, freed_space: freedSpace, message: `Deleted ${toDelete.length} snapshots`, }) } catch (error) { console.error('Cleanup snapshots error:', error) return internalError('Failed to cleanup snapshots') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots-v2.ts ================================================ /** * API V2 - * : /api/tab/bookmarks/:id/snapshots-v2 */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' import { generateSignedUrl } from '../../../../lib/signed-url' import { checkR2Quota } from '../../../../lib/storage-quota' import { decodeBase64Image, uploadImagesConcurrently, replaceImagePlaceholders, } from './snapshot-upload' function generateNanoId(): string { const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' const randomValues = new Uint8Array(21) crypto.getRandomValues(randomValues) let id = '' for (let i = 0; i < 21; i++) { id += alphabet[randomValues[i] % alphabet.length] } return id } async function sha256(content: string): Promise { const data = new TextEncoder().encode(content) const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') } interface CreateSnapshotV2Request { html_content: string title: string url: string images: Array<{ hash: string; data: string; type: string }> force?: boolean } // POST /api/tab/bookmarks/:id/snapshots-v2 - (V2) export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('bookmarks.create'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id as string try { const body = await context.request.json() as CreateSnapshotV2Request const { html_content, title, url, images = [], force = false } = body if (!html_content || !title || !url) { return badRequest('Missing required fields') } const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // & () const [contentHash, latestSnapshot, versionResult] = await Promise.all([ sha256(html_content), force ? Promise.resolve(null) : db .prepare('SELECT content_hash FROM bookmark_snapshots WHERE bookmark_id = ? AND is_latest = 1') .bind(bookmarkId) .first(), db .prepare('SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM bookmark_snapshots WHERE bookmark_id = ?') .bind(bookmarkId) .first(), ]) if (!force && latestSnapshot && latestSnapshot.content_hash === contentHash) { return success({ message: 'Content unchanged, no new snapshot created', is_duplicate: true }) } const version = (versionResult && typeof versionResult.next_version === 'number') ? versionResult.next_version : 1 const timestamp = Date.now() // 1. (CPU ,) const decoded = images .map(decodeBase64Image) .filter((d): d is NonNullable => d !== null) // 2. : const htmlBytes = new TextEncoder().encode(html_content) const totalImageBytes = decoded.reduce((sum, d) => sum + d.bytes.length, 0) const totalSize = htmlBytes.length + totalImageBytes const quota = await checkR2Quota(db, context.env, totalSize) if (!quota.allowed) { const usedGB = quota.usedBytes / (1024 * 1024 * 1024) const limitGB = quota.limitBytes / (1024 * 1024 * 1024) return badRequest({ code: 'R2_STORAGE_LIMIT_EXCEEDED', message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB.`, }) } // 3. R2(6) const { uploadedHashes, totalImageSize } = await uploadImagesConcurrently( decoded, bucket, userId, bookmarkId, version, timestamp ) // 4. HTML const processedHtml = replaceImagePlaceholders( html_content, uploadedHashes, userId, bookmarkId, version ) // 5. HTML const htmlKey = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html` const processedHtmlBytes = new TextEncoder().encode(processedHtml) await bucket.put(htmlKey, processedHtmlBytes, { httpMetadata: { contentType: 'text/html; charset=utf-8' }, customMetadata: { userId, bookmarkId, version: version.toString(), title, imageCount: uploadedHashes.length.toString(), snapshotVersion: '2', }, }) // 6. const snapshotId = generateNanoId() const now = new Date().toISOString() const finalSize = processedHtmlBytes.length + totalImageSize await db.batch([ db.prepare( `INSERT INTO bookmark_snapshots (id, bookmark_id, user_id, version, is_latest, content_hash, r2_key, r2_bucket, file_size, mime_type, snapshot_url, snapshot_title, snapshot_status, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)` ).bind( snapshotId, bookmarkId, userId, version, contentHash, htmlKey, finalSize, url, title, now, now ), db.prepare('UPDATE bookmark_snapshots SET is_latest = 0 WHERE bookmark_id = ? AND user_id = ? AND id != ?') .bind(bookmarkId, userId, snapshotId), db.prepare( 'UPDATE bookmarks SET has_snapshot = 1, latest_snapshot_at = ?, snapshot_count = snapshot_count + 1 WHERE id = ? AND user_id = ?' ).bind(now, bookmarkId, userId), ]) // URL(24 ) const baseUrl = new URL(context.request.url).origin const { signature, expires } = await generateSignedUrl( { userId, resourceId: snapshotId, expiresIn: 24 * 3600, action: 'view' }, context.env.JWT_SECRET ) const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshotId}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view` return success({ snapshot: { id: snapshotId, version, file_size: finalSize, image_count: uploadedHashes.length, content_hash: contentHash, snapshot_title: title, is_latest: true, created_at: now, view_url: viewUrl, }, message: 'Snapshot created successfully (V2)', }) } catch (error) { console.error('[Snapshot V2 API] Error:', error) return internalError('Failed to create snapshot') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/snapshots.ts ================================================ /** * API * : /api/tab/bookmarks/:id/snapshots * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' import { checkR2Quota } from '../../../../lib/storage-quota' // nanoid ID(21 ) function generateNanoId(): string { const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' const length = 21 const randomValues = new Uint8Array(length) crypto.getRandomValues(randomValues) let id = '' for (let i = 0; i < length; i++) { id += alphabet[randomValues[i] % alphabet.length] } return id } // Web Crypto API SHA-256 async function sha256(content: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(content) const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') } interface CreateSnapshotRequest { html_content: string title: string url: string force?: boolean } // const MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB // GET /api/tab/bookmarks/:id/snapshots - export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('bookmarks.read'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const db = context.env.DB // const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // const snapshots = await db .prepare( `SELECT id, version, file_size, content_hash, snapshot_title, is_latest, created_at FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC` ) .bind(bookmarkId, userId) .all() return success({ snapshots: snapshots.results || [], total: snapshots.results?.length || 0, }) } catch (error) { console.error('Get snapshots error:', error) return internalError('Failed to get snapshots') } }, ] // POST /api/tab/bookmarks/:id/snapshots - export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('bookmarks.create'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const body = await context.request.json() as CreateSnapshotRequest const { html_content, title, url, force = false } = body if (!html_content || !title || !url) { return badRequest('Missing required fields') } // const originalSize = new Blob([html_content]).size if (originalSize > MAX_SNAPSHOT_SIZE) { return badRequest( `Snapshot too large (${(originalSize / 1024 / 1024).toFixed(2)}MB). Maximum size is ${MAX_SNAPSHOT_SIZE / 1024 / 1024}MB.` ) } // data URL () const dataUrlCount = (html_content.match(/src="data:/g) || []).length console.log(`[Snapshot API] Received HTML: ${(originalSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`) const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // const contentHash = await sha256(html_content) // () if (!force) { const latestSnapshot = await db .prepare( `SELECT content_hash FROM bookmark_snapshots WHERE bookmark_id = ? AND is_latest = 1` ) .bind(bookmarkId) .first() if (latestSnapshot && latestSnapshot.content_hash === contentHash) { return success({ message: 'Content unchanged, no new snapshot created', is_duplicate: true, }) } } // const versionResult = await db .prepare( `SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM bookmark_snapshots WHERE bookmark_id = ?` ) .bind(bookmarkId) .first() const version = versionResult?.next_version as number || 1 // R2 const timestamp = Date.now() const r2Key = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html` // HTML UTF-8 const encoder = new TextEncoder() const htmlBytes = encoder.encode(html_content) console.log(`[Snapshot API] Encoded to UTF-8: ${(htmlBytes.length / 1024).toFixed(1)}KB`) // const quota = await checkR2Quota(db, context.env, htmlBytes.length) if (!quota.allowed) { const usedGB = quota.usedBytes / (1024 * 1024 * 1024) const limitGB = quota.limitBytes / (1024 * 1024 * 1024) return badRequest({ code: 'R2_STORAGE_LIMIT_EXCEEDED', message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB. Please delete some snapshots or images and try again.`, }) } // UTF-8 R2 await bucket.put(r2Key, htmlBytes, { httpMetadata: { contentType: 'text/html; charset=utf-8', }, customMetadata: { userId, bookmarkId, version: version.toString(), title, fileSize: htmlBytes.length.toString(), dataUrlCount: dataUrlCount.toString(), }, }) console.log(`[Snapshot API] Uploaded to R2: ${r2Key}`) const snapshotId = generateNanoId() const now = new Date().toISOString() // ( INSERT ,) const batch = [ // , db.prepare( `INSERT INTO bookmark_snapshots (id, bookmark_id, user_id, version, is_latest, content_hash, r2_key, r2_bucket, file_size, mime_type, snapshot_url, snapshot_title, snapshot_status, created_at, updated_at) VALUES (?, ?, ?, (SELECT COALESCE(MAX(version), 0) + 1 FROM bookmark_snapshots WHERE bookmark_id = ?), 1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)` ).bind( snapshotId, bookmarkId, userId, bookmarkId, contentHash, r2Key, originalSize, url, title, now, now ), // is_latest db.prepare( `UPDATE bookmark_snapshots SET is_latest = 0 WHERE bookmark_id = ? AND id != ?` ).bind(bookmarkId, snapshotId), // () db.prepare( `UPDATE bookmarks SET has_snapshot = 1, latest_snapshot_at = ?, snapshot_count = snapshot_count + 1 WHERE id = ?` ).bind(now, bookmarkId), ] await db.batch(batch) // await cleanupOldSnapshots(db, bucket, bookmarkId, userId) return success({ snapshot: { id: snapshotId, version, file_size: originalSize, content_hash: contentHash, created_at: now, }, message: 'Snapshot created successfully', }) } catch (error) { console.error('Create snapshot error:', error) return internalError('Failed to create snapshot') } }, ] // async function cleanupOldSnapshots( db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string ) { try { // const bookmarkSettings = await db .prepare('SELECT snapshot_retention_count FROM bookmarks WHERE id = ?') .bind(bookmarkId) .first() const userSettings = await db .prepare('SELECT snapshot_retention_count FROM user_preferences WHERE user_id = ?') .bind(userId) .first() const retentionCount = (bookmarkSettings?.snapshot_retention_count as number | null) ?? (userSettings?.snapshot_retention_count as number | null) ?? 5 // -1 if (retentionCount === -1) { return } // const toDelete = await db .prepare( `SELECT id, r2_key FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC LIMIT -1 OFFSET ?` ) .bind(bookmarkId, userId, retentionCount) .all() if (!toDelete.results || toDelete.results.length === 0) { return } // R2 () const deletedIds: unknown[] = [] for (const snapshot of toDelete.results) { try { await bucket.delete(snapshot.r2_key as string) deletedIds.push(snapshot.id) } catch (error) { console.error('Failed to delete R2 file:', snapshot.r2_key, error) } } if (deletedIds.length === 0) return // R2 const placeholders = deletedIds.map(() => '?').join(',') await db .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders})`) .bind(...deletedIds) .run() } catch (error) { console.error('Cleanup snapshots error:', error) } } ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id]/trash.ts ================================================ /** * API - * : /api/tab/bookmarks/:id/trash * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' // PATCH /api/tab/bookmarks/:id/trash - () export const onRequestPatch: PagesFunction[] = [ requireApiKeyAuth('bookmarks.delete'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { // const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found') } const now = new Date().toISOString() // : deleted_at await context.env.DB.prepare( 'UPDATE bookmarks SET deleted_at = ?, updated_at = ? WHERE id = ?' ) .bind(now, now, bookmarkId) .run() return success({ message: 'Bookmark moved to trash' }) } catch (error) { console.error('Trash bookmark error:', error) return internalError('Failed to trash bookmark') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/[id].ts ================================================ /** * API - * : /api/tab/bookmarks/:id * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types' import { success, badRequest, notFound, noContent, internalError } from '../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' import { isValidUrl, sanitizeString } from '../../../lib/validation' import { normalizeBookmark } from '../../../lib/bookmark-utils' import { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags' import { invalidatePublicShareCache } from '../../shared/cache' interface UpdateBookmarkRequest { title?: string url?: string description?: string cover_image?: string favicon?: string tag_ids?: string[] // : ID tags?: string[] // :() is_pinned?: boolean is_archived?: boolean is_public?: boolean } // GET /api/bookmarks/:id - export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('bookmarks.read'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const bookmarkRow = await context.env.DB.prepare( 'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!bookmarkRow) { return notFound('Bookmark not found') } const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL` ) .bind(bookmarkId, userId) .all<{ id: string; name: string; color: string | null }>() const snapshotCountResult = await context.env.DB.prepare( `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?` ) .bind(bookmarkId, userId) .first<{ count: number }>() const snapshotCount = snapshotCountResult?.count || 0 await invalidatePublicShareCache(context.env, userId) return success({ bookmark: { ...normalizeBookmark(bookmarkRow), tags: tags || [], snapshot_count: snapshotCount, has_snapshot: snapshotCount > 0, }, }) } catch (error) { console.error('Get bookmark error:', error) return internalError('Failed to get bookmark') } }, ] // PATCH /api/bookmarks/:id - export const onRequestPatch: PagesFunction[] = [ requireApiKeyAuth('bookmarks.update'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found') } const body = (await context.request.json()) as UpdateBookmarkRequest const updates: string[] = [] const values: SQLParam[] = [] if (body.title !== undefined) { if (!body.title.trim()) return badRequest('Title cannot be empty') updates.push('title = ?') values.push(sanitizeString(body.title, 500)) } if (body.url !== undefined) { if (!body.url.trim()) return badRequest('URL cannot be empty') if (!isValidUrl(body.url)) return badRequest('Invalid URL format') updates.push('url = ?') values.push(sanitizeString(body.url, 2000)) } if (body.description !== undefined) { updates.push('description = ?') values.push(body.description ? sanitizeString(body.description, 1000) : null) } if (body.cover_image !== undefined) { updates.push('cover_image = ?') values.push(body.cover_image ? sanitizeString(body.cover_image, 2000) : null) } if (body.favicon !== undefined) { updates.push('favicon = ?') values.push(body.favicon ? sanitizeString(body.favicon, 2000) : null) } if (body.is_pinned !== undefined) { updates.push('is_pinned = ?') values.push(body.is_pinned ? 1 : 0) } if (body.is_archived !== undefined) { updates.push('is_archived = ?') values.push(body.is_archived ? 1 : 0) } if (body.is_public !== undefined) { updates.push('is_public = ?') values.push(body.is_public ? 1 : 0) } const now = new Date().toISOString() if (updates.length > 0) { updates.push('updated_at = ?') values.push(now) values.push(bookmarkId, userId) await context.env.DB.prepare( `UPDATE bookmarks SET ${updates.join(', ')} WHERE id = ? AND user_id = ?` ) .bind(...values) .run() } if (body.tags !== undefined) { await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now) } else if (body.tag_ids !== undefined) { const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids) await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now) } const bookmarkRow = await context.env.DB.prepare( 'SELECT * FROM bookmarks WHERE id = ? AND user_id = ?' ) .bind(bookmarkId, userId) .first() const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND bt.user_id = ?` ) .bind(bookmarkId, userId) .all<{ id: string; name: string; color: string | null }>() if (!bookmarkRow) { return internalError('Failed to load bookmark after update') } return success({ bookmark: { ...normalizeBookmark(bookmarkRow), tags: tags || [], }, }) } catch (error) { console.error('Update bookmark error:', error) return internalError('Failed to update bookmark') } }, ] // DELETE /api/bookmarks/:id - export const onRequestDelete: PagesFunction[] = [ requireApiKeyAuth('bookmarks.delete'), async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found') } const now = new Date().toISOString() await context.env.DB.prepare( 'UPDATE bookmarks SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' ) .bind(now, now, bookmarkId, userId) .run() await invalidatePublicShareCache(context.env, userId) return noContent() } catch (error) { console.error('Delete bookmark error:', error) return internalError('Failed to delete bookmark') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/batch/index.ts ================================================ /** * API * : /api/tab/bookmarks/batch * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, badRequest, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' import { isValidUrl, sanitizeString } from '../../../../lib/validation' import { generateUUID } from '../../../../lib/crypto' import { invalidatePublicShareCache } from '../../../shared/cache' import { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../../lib/tags' interface BatchCreateBookmarkItem { title: string url: string description?: string cover_image?: string favicon?: string tags?: string[] is_pinned?: boolean is_archived?: boolean is_public?: boolean } interface BatchCreateRequest { bookmarks: BatchCreateBookmarkItem[] } interface BatchCreateResult { success: number failed: number skipped: number total: number errors?: Array<{ index: number url: string error: string }> created_bookmarks: Array<{ id: string url: string title: string }> } /** * GET /api/tab/bookmarks/batch */ export const onRequestGet: PagesFunction[] = [ async () => { return new Response(JSON.stringify({ error: { code: 'METHOD_NOT_ALLOWED', message: 'GET method is not supported for batch operations. Use POST instead.' } }), { status: 405, headers: { 'Content-Type': 'application/json', 'Allow': 'POST' } }) } ] /** * POST /api/tab/bookmarks/batch * */ export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('bookmarks.create'), async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as BatchCreateRequest if (!body.bookmarks || !Array.isArray(body.bookmarks) || body.bookmarks.length === 0) { return badRequest('bookmarks array is required and cannot be empty') } if (body.bookmarks.length > 100) { return badRequest('Cannot create more than 100 bookmarks at once') } const result: BatchCreateResult = { success: 0, failed: 0, skipped: 0, total: body.bookmarks.length, errors: [], created_bookmarks: [] } const now = new Date().toISOString() for (let i = 0; i < body.bookmarks.length; i++) { const item = body.bookmarks[i] try { if (!item.title || !item.url) { result.failed++ result.errors!.push({ index: i, url: item.url || '', error: 'Title and URL are required' }) continue } if (!isValidUrl(item.url)) { result.failed++ result.errors!.push({ index: i, url: item.url, error: 'Invalid URL format' }) continue } const title = sanitizeString(item.title, 500) const url = sanitizeString(item.url, 2000) const description = item.description ? sanitizeString(item.description, 1000) : null const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null const isPinned = item.is_pinned ? 1 : 0 const isArchived = item.is_archived ? 1 : 0 const isPublic = item.is_public ? 1 : 0 const existing = await context.env.DB.prepare( 'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?' ) .bind(userId, url) .first<{ id: string; deleted_at: string | null }>() const restoredDeletedBookmark = Boolean(existing?.deleted_at) let bookmarkId: string if (existing) { if (!existing.deleted_at) { result.skipped++ continue } bookmarkId = existing.id await context.env.DB.prepare( `UPDATE bookmarks SET title = ?, description = ?, cover_image = ?, favicon = ?, is_pinned = ?, is_archived = ?, is_public = ?, deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?` ) .bind(title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId) .run() } else { bookmarkId = generateUUID() await context.env.DB.prepare( `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind(bookmarkId, userId, title, url, description, coverImage, null, favicon, isPinned, isArchived, isPublic, now, now) .run() } if (item.tags !== undefined) { await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, item.tags, userId, now) } else if (restoredDeletedBookmark) { await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now) } result.success++ result.created_bookmarks.push({ id: bookmarkId, url, title }) } catch (error) { result.failed++ result.errors!.push({ index: i, url: item.url || '', error: 'Failed to create bookmark' }) console.error(`[Batch Create] Failed to create bookmark ${i}:`, error) } } if (result.errors!.length === 0) { delete result.errors } if (result.success > 0) { await context.env.DB.prepare( `UPDATE tags SET usage_count = ( SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = tags.id AND user_id = ? ) WHERE user_id = ? AND deleted_at IS NULL` ) .bind(userId, userId) .run() } await context.env.DB.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, created_at) VALUES (?, 'batch_create_bookmarks', ?, datetime('now'))` ) .bind(userId, JSON.stringify({ total: result.total, success: result.success, failed: result.failed, skipped: result.skipped })) .run() await invalidatePublicShareCache(context.env, userId) return success(result) } catch (error) { console.error('Batch create bookmarks error:', error) return internalError('Failed to batch create bookmarks') } } ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/batch-handler.ts ================================================ /** */ import type { EventContext } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import type { ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' import { success, badRequest } from '../../../lib/response' import { isValidUrl, sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' import { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags' import { invalidatePublicShareCache } from '../../shared/cache' interface BatchCreateBookmarkItem { title: string url: string description?: string cover_image?: string favicon?: string tags?: string[] is_pinned?: boolean is_archived?: boolean is_public?: boolean } interface BatchCreateResult { success: number failed: number skipped: number total: number errors?: Array<{ index: number url: string error: string }> created_bookmarks: Array<{ id: string url: string title: string }> } export async function batchCreateBookmarks( context: EventContext, userId: string, bookmarks: BatchCreateBookmarkItem[] ): Promise { console.log('[Batch Handler] Starting batch create') console.log('[Batch Handler] User ID:', userId) console.log('[Batch Handler] Bookmarks count:', bookmarks?.length) if (!bookmarks || !Array.isArray(bookmarks) || bookmarks.length === 0) { return badRequest('bookmarks array is required and cannot be empty') } // if (bookmarks.length > 100) { return badRequest('Cannot create more than 100 bookmarks at once') } const result: BatchCreateResult = { success: 0, failed: 0, skipped: 0, total: bookmarks.length, errors: [], created_bookmarks: [] } const now = new Date().toISOString() // for (let i = 0; i < bookmarks.length; i++) { const item = bookmarks[i] try { // if (!item.title || !item.url) { result.failed++ result.errors!.push({ index: i, url: item.url || '', error: 'Title and URL are required' }) continue } // URL if (!isValidUrl(item.url)) { result.failed++ result.errors!.push({ index: i, url: item.url, error: 'Invalid URL format' }) continue } const title = sanitizeString(item.title, 500) const url = sanitizeString(item.url, 2000) const description = item.description ? sanitizeString(item.description, 1000) : null const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null const isPinned = item.is_pinned ? 1 : 0 const isArchived = item.is_archived ? 1 : 0 const isPublic = item.is_public ? 1 : 0 const existing = await context.env.DB.prepare( 'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?' ) .bind(userId, url) .first<{ id: string; deleted_at: string | null }>() const restoredDeletedBookmark = Boolean(existing?.deleted_at) let bookmarkId: string if (existing) { if (!existing.deleted_at) { // , result.skipped++ continue } // bookmarkId = existing.id await context.env.DB.prepare( `UPDATE bookmarks SET title = ?, description = ?, cover_image = ?, favicon = ?, is_pinned = ?, is_archived = ?, is_public = ?, deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?` ) .bind( title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId ) .run() } else { bookmarkId = generateUUID() await context.env.DB.prepare( `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( bookmarkId, userId, title, url, description, coverImage, null, // cover_image_id favicon, isPinned, isArchived, isPublic, now, now ) .run() } // if (item.tags !== undefined) { await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, item.tags, userId, now) } else if (restoredDeletedBookmark) { await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now) } result.success++ result.created_bookmarks.push({ id: bookmarkId, url, title }) } catch (error) { result.failed++ console.error(`[Batch Handler] Failed to create bookmark ${i}:`, error) result.errors!.push({ index: i, url: item.url || '', error: 'Failed to create bookmark' }) } } if (result.errors!.length === 0) { delete result.errors } // usage_count if (result.success > 0) { await context.env.DB.prepare( `UPDATE tags SET usage_count = ( SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = tags.id AND user_id = ? ) WHERE user_id = ? AND deleted_at IS NULL` ) .bind(userId, userId) .run() } // await invalidatePublicShareCache(context.env, userId) console.log('[Batch Handler] Complete:', result) return success(result) } ================================================ FILE: tmarks/functions/api/tab/bookmarks/bookmark-batch.ts ================================================ import type { D1Database } from '../../../lib/types' import { isValidUrl, sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' import { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags' export type BatchCreateResult = { success: number failed: number skipped: number total: number errors?: Array<{ index: number; url: string; error: string }> created_bookmarks: Array<{ id: string; url: string; title: string }> } export async function handleBatchCreate( db: D1Database, userId: string, bookmarks: Array<{ title: string url: string description?: string cover_image?: string favicon?: string tags?: string[] is_pinned?: boolean is_archived?: boolean is_public?: boolean }>, now: string ): Promise { const result: BatchCreateResult = { success: 0, failed: 0, skipped: 0, total: bookmarks.length, errors: [], created_bookmarks: [], } for (let i = 0; i < bookmarks.length; i++) { const item = bookmarks[i] try { if (!item.title || !item.url) { result.failed++ result.errors.push({ index: i, url: item.url || '', error: 'Title and URL are required', }) continue } if (!isValidUrl(item.url)) { result.failed++ result.errors.push({ index: i, url: item.url, error: 'Invalid URL format', }) continue } const title = sanitizeString(item.title, 500) const url = sanitizeString(item.url, 2000) const description = item.description ? sanitizeString(item.description, 1000) : null const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null const isPinned = item.is_pinned ? 1 : 0 const isArchived = item.is_archived ? 1 : 0 const isPublic = item.is_public ? 1 : 0 const existing = await db.prepare( 'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?' ) .bind(userId, url) .first<{ id: string; deleted_at: string | null }>() const restoredDeletedBookmark = Boolean(existing?.deleted_at) let bookmarkId: string if (existing) { if (!existing.deleted_at) { result.skipped++ continue } bookmarkId = existing.id await db.prepare( `UPDATE bookmarks SET title = ?, description = ?, cover_image = ?, favicon = ?, is_pinned = ?, is_archived = ?, is_public = ?, deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?` ) .bind(title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId) .run() } else { bookmarkId = generateUUID() await db.prepare( `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind(bookmarkId, userId, title, url, description, coverImage, null, favicon, isPinned, isArchived, isPublic, now, now) .run() } if (item.tags !== undefined) { await replaceBookmarkTagsByNames(db, bookmarkId, item.tags, userId, now) } else if (restoredDeletedBookmark) { await replaceBookmarkTags(db, bookmarkId, userId, [], now) } result.success++ result.created_bookmarks.push({ id: bookmarkId, url, title }) } catch (error) { result.failed++ result.errors.push({ index: i, url: item.url || '', error: 'Failed to create bookmark', }) console.error(`[Batch] Failed to create bookmark ${i}:`, error) } } if (result.errors.length === 0) { delete result.errors } if (result.success > 0) { await db.prepare( `UPDATE tags SET usage_count = ( SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = tags.id AND user_id = ? ) WHERE user_id = ? AND deleted_at IS NULL` ) .bind(userId, userId) .run() } return result } ================================================ FILE: tmarks/functions/api/tab/bookmarks/bookmark-list.ts ================================================ import type { Bookmark, BookmarkRow, SQLParam, D1Database } from '../../../lib/types' export interface BookmarkWithTags extends Bookmark { tags: Array<{ id: string; name: string; color: string | null }> } export interface BookmarkListRow extends BookmarkRow { pin_order?: number | null } export interface BookmarkPageCursor { id: string isPinned: boolean pinOrder: number | null sortValue: string } export function parseBookmarkPageCursor(raw: string | null): BookmarkPageCursor | null { if (!raw) return null try { const parsed = JSON.parse(raw) as Partial if ( typeof parsed?.id === 'string' && typeof parsed?.isPinned === 'boolean' && typeof parsed?.sortValue === 'string' && (typeof parsed?.pinOrder === 'number' || parsed?.pinOrder === null || parsed?.pinOrder === undefined) ) { return { id: parsed.id, isPinned: parsed.isPinned, pinOrder: typeof parsed.pinOrder === 'number' ? parsed.pinOrder : null, sortValue: parsed.sortValue, } } } catch { return null } return null } export function createBookmarkPageCursor(row: BookmarkListRow, sortBy: 'created' | 'updated' | 'pinned'): string { return JSON.stringify({ id: row.id, isPinned: Boolean(row.is_pinned), pinOrder: row.is_pinned ? Number(row.pin_order ?? 0) : null, sortValue: sortBy === 'updated' ? row.updated_at : row.created_at, } satisfies BookmarkPageCursor) } export function buildBookmarkListQuery( userId: string, url: URL ): { query: string; params: SQLParam[]; pageSize: number; sortBy: 'created' | 'updated' | 'pinned' } { const keyword = url.searchParams.get('keyword') const tags = url.searchParams.get('tags') const pageSize = Math.max(1, Math.min(parseInt(url.searchParams.get('page_size') || '100') || 100, 200)) const pageCursor = url.searchParams.get('page_cursor') const parsedCursor = parseBookmarkPageCursor(pageCursor) const sortBy = (url.searchParams.get('sort') as 'created' | 'updated' | 'pinned') || 'created' const pinnedParam = url.searchParams.get('pinned') const pinned = pinnedParam ? pinnedParam === 'true' : undefined const sortField = sortBy === 'updated' ? 'b.updated_at' : 'b.created_at' // let query = ` SELECT DISTINCT b.* FROM bookmarks b WHERE b.user_id = ? AND b.deleted_at IS NULL ` const params: SQLParam[] = [userId] // if (pinned !== undefined) { query += ` AND b.is_pinned = ?` params.push(pinned ? 1 : 0) } if (keyword) { query += ` AND (b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)` const searchPattern = `%${keyword}%` params.push(searchPattern, searchPattern, searchPattern) } // (:) if (tags) { const tagIds = tags.split(',').filter(Boolean) if (tagIds.length > 0) { query += ` AND b.id IN ( SELECT bt.bookmark_id FROM bookmark_tags bt WHERE bt.tag_id IN (${tagIds.map(() => '?').join(',')}) GROUP BY bt.bookmark_id HAVING COUNT(DISTINCT bt.tag_id) = ? )` params.push(...tagIds, tagIds.length) } } // if (parsedCursor) { if (pinned === true) { query += ` AND ( b.pin_order > ? OR (b.pin_order = ? AND (${sortField} < ? OR (${sortField} = ? AND b.id < ?))) )` params.push( parsedCursor.pinOrder ?? 0, parsedCursor.pinOrder ?? 0, parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id ) } else if (pinned === false) { query += ` AND ( ${sortField} < ? OR (${sortField} = ? AND b.id < ?) )` params.push(parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id) } else if (parsedCursor.isPinned) { query += ` AND ( b.is_pinned = 0 OR ( b.is_pinned = 1 AND ( b.pin_order > ? OR (b.pin_order = ? AND (${sortField} < ? OR (${sortField} = ? AND b.id < ?))) ) ) )` params.push( parsedCursor.pinOrder ?? 0, parsedCursor.pinOrder ?? 0, parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id ) } else { query += ` AND b.is_pinned = 0 AND ( ${sortField} < ? OR (${sortField} = ? AND b.id < ?) )` params.push(parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id) } } else if (pageCursor) { query += ` AND b.id < ?` params.push(pageCursor) } let orderBy = '' switch (sortBy) { case 'updated': orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.updated_at DESC, b.id DESC' break case 'pinned': orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.created_at DESC, b.id DESC' break case 'created': default: orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.created_at DESC, b.id DESC' break } query += ` ${orderBy} LIMIT ?` params.push(pageSize + 1) return { query, params, pageSize, sortBy } } export async function fetchBookmarkTags( db: D1Database, bookmarkIds: string[] ): Promise>> { const tagsByBookmarkId = new Map>() if (bookmarkIds.length === 0) return tagsByBookmarkId const placeholders = bookmarkIds.map(() => '?').join(',') const { results: tagResults } = await db.prepare( `SELECT bt.bookmark_id, t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id IN (${placeholders}) AND t.deleted_at IS NULL ORDER BY bt.bookmark_id, t.name` ) .bind(...bookmarkIds) .all<{ bookmark_id: string; id: string; name: string; color: string | null }>() const allTags = tagResults ?? [] for (const tag of allTags) { if (!tagsByBookmarkId.has(tag.bookmark_id)) { tagsByBookmarkId.set(tag.bookmark_id, []) } tagsByBookmarkId.get(tag.bookmark_id)!.push({ id: tag.id, name: tag.name, color: tag.color, }) } return tagsByBookmarkId } ================================================ FILE: tmarks/functions/api/tab/bookmarks/index.ts ================================================ /** * API - * : /api/tab/bookmarks * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow, RouteParams } from '../../../lib/types' import { success, badRequest, created, internalError } from '../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' import { isValidUrl, sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' import { normalizeBookmark } from '../../../lib/bookmark-utils' import { invalidatePublicShareCache } from '../../shared/cache' import { uploadCoverImageToR2 } from '../../../lib/image-upload' import { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags' import { buildBookmarkListQuery, fetchBookmarkTags, createBookmarkPageCursor, BookmarkListRow, BookmarkWithTags } from './bookmark-list' import { handleBatchCreate } from './bookmark-batch' interface CreateBookmarkRequest { title?: string url?: string description?: string cover_image?: string favicon?: string tag_ids?: string[] // : tags?: string[] // :( is_pinned?: boolean is_public?: boolean bookmarks?: Array<{ // title: string url: string description?: string cover_image?: string favicon?: string tags?: string[] is_pinned?: boolean is_archived?: boolean is_public?: boolean }> } // GET /api/bookmarks - export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('bookmarks.read'), async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) try { const { query, params, pageSize, sortBy } = buildBookmarkListQuery(userId, url) const { results } = await context.env.DB.prepare(query).bind(...params).all() const hasMore = results.length > pageSize const bookmarks = hasMore ? results.slice(0, pageSize) : results const nextCursor = hasMore && bookmarks.length > 0 ? createBookmarkPageCursor(bookmarks[bookmarks.length - 1], sortBy) : null const bookmarkIds = bookmarks.map(b => b.id) const tagsByBookmarkId = await fetchBookmarkTags(context.env.DB, bookmarkIds) const bookmarksWithTags: BookmarkWithTags[] = bookmarks.map(row => { const normalized = normalizeBookmark(row) return { ...normalized, tags: tagsByBookmarkId.get(row.id) || [], } }) return success({ bookmarks: bookmarksWithTags, meta: { page_size: pageSize, count: bookmarks.length, next_cursor: nextCursor, has_more: hasMore, }, }) } catch (error) { console.error('Get bookmarks error:', error) return internalError('Failed to get bookmarks') } }, ] export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('bookmarks.create'), async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as CreateBookmarkRequest // if (body.bookmarks && Array.isArray(body.bookmarks) && body.bookmarks.length > 0) { if (body.bookmarks.length > 100) { return badRequest('Cannot create more than 100 bookmarks at once') } const now = new Date().toISOString() const result = await handleBatchCreate(context.env.DB, userId, body.bookmarks, now) await invalidatePublicShareCache(context.env, userId) return success(result) } // if (!body.title || !body.url) { return badRequest({ message: 'Title and URL are required', code: 'MISSING_FIELDS' }) } if (!isValidUrl(body.url)) { return badRequest('Invalid URL format') } const title = sanitizeString(body.title, 500) const url = sanitizeString(body.url, 2000) const description = body.description ? sanitizeString(body.description, 1000) : null let coverImage = body.cover_image ? sanitizeString(body.cover_image, 2000) : null const favicon = body.favicon ? sanitizeString(body.favicon, 2000) : null const existing = await context.env.DB.prepare( 'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?' ) .bind(userId, url) .first<{ id: string; deleted_at: string | null }>() const restoredDeletedBookmark = Boolean(existing?.deleted_at) const now = new Date().toISOString() let bookmarkId: string const isPinned = body.is_pinned ? 1 : 0 const isPublic = body.is_public ? 1 : 0 // R2 bucket, R2 let coverImageId: string | null = null if (coverImage && context.env.SNAPSHOTS_BUCKET && context.env.R2_PUBLIC_URL) { const tempBookmarkId = existing?.id || generateUUID() const uploadResult = await uploadCoverImageToR2( coverImage, userId, tempBookmarkId, context.env.SNAPSHOTS_BUCKET, context.env.DB, context.env.R2_PUBLIC_URL, context.env ) if (uploadResult.success && uploadResult.r2Url) { coverImage = uploadResult.r2Url coverImageId = uploadResult.imageId || null } } if (existing) { if (!existing.deleted_at) { // const bookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?') .bind(existing.id, userId) .first() const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND bt.user_id = ?` ) .bind(existing.id, userId) .all<{ id: string; name: string; color: string | null }>() const snapshotCountResult = await context.env.DB.prepare( `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?` ) .bind(existing.id, userId) .first<{ count: number }>() const snapshotCount = snapshotCountResult?.count || 0 if (!bookmarkRow) { return internalError('Failed to retrieve bookmark') } const bookmark = normalizeBookmark(bookmarkRow) return success( { bookmark: { ...bookmark, tags: tags || [], snapshot_count: snapshotCount, has_snapshot: snapshotCount > 0, }, }, { message: 'Bookmark already exists', code: 'BOOKMARK_EXISTS', } ) } // bookmarkId = existing.id await context.env.DB.prepare( `UPDATE bookmarks SET title = ?, description = ?, cover_image = ?, favicon = ?, is_pinned = ?, is_public = ?, deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?` ) .bind(title, description, coverImage, favicon, isPinned, isPublic, now, bookmarkId, userId) .run() } else { bookmarkId = generateUUID() await context.env.DB.prepare( `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind(bookmarkId, userId, title, url, description, coverImage, coverImageId, favicon, isPinned, isPublic, now, now) .run() } // if (body.tags !== undefined) { await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now) } else if (body.tag_ids !== undefined) { const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids) await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now) } else if (restoredDeletedBookmark) { await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now) } const bookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?') .bind(bookmarkId, userId) .first() const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND bt.user_id = ?` ) .bind(bookmarkId, userId) .all<{ id: string; name: string; color: string | null }>() if (!bookmarkRow) { return internalError('Failed to load bookmark after creation') } await invalidatePublicShareCache(context.env, userId) return created({ bookmark: { ...normalizeBookmark(bookmarkRow), tags: tags || [], }, }) } catch (error) { console.error('Create bookmark error:', error) return internalError('Failed to create bookmark') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/reorder-pinned.ts ================================================ /** * * POST /api/tab/bookmarks/reorder-pinned */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { success, badRequest, internalError } from '../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' interface ReorderPinnedRequest { bookmark_ids: string[] } export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('bookmarks.write'), async (context) => { try { const userId = context.data.user_id const body = (await context.request.json()) as ReorderPinnedRequest if (!body.bookmark_ids || !Array.isArray(body.bookmark_ids) || body.bookmark_ids.length === 0) { return badRequest('bookmark_ids is required and must be a non-empty array') } // const placeholders = body.bookmark_ids.map(() => '?').join(',') const { results: bookmarks } = await context.env.DB.prepare( `SELECT id FROM bookmarks WHERE id IN (${placeholders}) AND user_id = ? AND is_pinned = 1 AND deleted_at IS NULL` ) .bind(...body.bookmark_ids, userId) .all<{ id: string }>() if (bookmarks.length !== body.bookmark_ids.length) { return badRequest('Some bookmarks are not found, not pinned, or do not belong to you') } // const now = new Date().toISOString() const updates = body.bookmark_ids.map((id, index) => { return context.env.DB.prepare( 'UPDATE bookmarks SET pin_order = ?, updated_at = ? WHERE id = ? AND user_id = ?' ).bind(index, now, id, userId) }) await context.env.DB.batch(updates) return success({ message: 'Pinned bookmarks reordered successfully', count: body.bookmark_ids.length, }) } catch (error) { console.error('Reorder pinned bookmarks error:', error) return internalError('Failed to reorder pinned bookmarks') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/trash/empty.ts ================================================ /** * : /api/tab/bookmarks/trash/empty * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../lib/types' import { success, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' export const onRequestDelete: PagesFunction[] = [ requireApiKeyAuth('bookmarks.delete'), async (context) => { const userId = context.data.user_id try { const { results: trashBookmarks } = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL' ) .bind(userId) .all<{ id: string }>() if (trashBookmarks.length === 0) { return success({ message: 'Trash is already empty', count: 0 }) } const bookmarkIds = trashBookmarks.map(b => b.id) // for (const id of bookmarkIds) { await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?') .bind(id) .run() } // for (const id of bookmarkIds) { await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?') .bind(id) .run() } // await context.env.DB.prepare( 'DELETE FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL' ) .bind(userId) .run() return success({ message: 'Trash emptied successfully', count: bookmarkIds.length, }) } catch (error) { console.error('Empty trash error:', error) return internalError('Failed to empty trash') } }, ] ================================================ FILE: tmarks/functions/api/tab/bookmarks/trash.ts ================================================ /** * : /api/tab/bookmarks/trash * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' import { normalizeBookmark } from '../../../lib/bookmark-utils' interface TrashQueryParams { page_size?: string page_cursor?: string sort?: string } export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('bookmarks.read'), async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const params: TrashQueryParams = { page_size: url.searchParams.get('page_size') || undefined, page_cursor: url.searchParams.get('page_cursor') || undefined, sort: url.searchParams.get('sort') || undefined, } try { const pageSize = Math.min(Math.max(parseInt(params.page_size || '20', 10) || 20, 1), 100) const sort = params.sort === 'deleted_at_asc' ? 'ASC' : 'DESC' let query = ` SELECT * FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL ` const queryParams: (string | number)[] = [userId] // if (params.page_cursor) { query += ` AND deleted_at < ?` queryParams.push(params.page_cursor) } query += ` ORDER BY deleted_at ${sort} LIMIT ?` queryParams.push(pageSize + 1) const { results: bookmarks } = await context.env.DB.prepare(query) .bind(...queryParams) .all() const hasMore = bookmarks.length > pageSize const items = hasMore ? bookmarks.slice(0, pageSize) : bookmarks const bookmarksWithTags = await Promise.all( items.map(async (bookmark) => { const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL` ) .bind(bookmark.id) .all<{ id: string; name: string; color: string | null }>() return { ...normalizeBookmark(bookmark), tags: tags || [], } }) ) // const countResult = await context.env.DB.prepare( 'SELECT COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL' ) .bind(userId) .first<{ count: number }>() return success({ bookmarks: bookmarksWithTags, meta: { total: countResult?.count || 0, page_size: pageSize, has_more: hasMore, next_cursor: hasMore && items.length > 0 ? items[items.length - 1].deleted_at : null, }, }) } catch (error) { console.error('Get trash bookmarks error:', error) return internalError('Failed to get trash bookmarks') } }, ] ================================================ FILE: tmarks/functions/api/tab/me.ts ================================================ /** * API - * : /api/tab/me * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../lib/types' import { success, internalError } from '../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../middleware/api-key-auth-pages' // GET /api/me - type BookmarkStats = { total_bookmarks: number | null pinned_bookmarks: number | null } export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('user.read'), async (context) => { const userId = context.data.user_id try { // const user = await context.env.DB.prepare( 'SELECT id, username, email, created_at FROM users WHERE id = ?' ) .bind(userId) .first() if (!user) { return internalError('User not found') } // const stats = await context.env.DB.prepare( `SELECT COUNT(CASE WHEN deleted_at IS NULL THEN 1 END) as total_bookmarks, COUNT(CASE WHEN deleted_at IS NULL AND is_pinned = 1 THEN 1 END) as pinned_bookmarks FROM bookmarks WHERE user_id = ?` ) .bind(userId) .first() const tagCount = await context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tags WHERE user_id = ? AND deleted_at IS NULL' ) .bind(userId) .first<{ count: number }>() return success({ user: { ...user, stats: { total_bookmarks: stats?.total_bookmarks ?? 0, pinned_bookmarks: stats?.pinned_bookmarks ?? 0, total_tags: tagCount?.count || 0, }, }, api_key: { id: context.data.api_key_id, permissions: context.data.api_key_permissions, }, }) } catch (error) { console.error('Get user info error:', error) return internalError('Failed to get user info') } }, ] ================================================ FILE: tmarks/functions/api/tab/search.ts ================================================ /** * External API - Global Search * Path: /api/tab/search * Auth: API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, Bookmark, RouteParams } from '../../lib/types' import { success, badRequest, internalError } from '../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../middleware/api-key-auth-pages' // GET /api/search - Global search for bookmarks and tags type BookmarkWithTags = Bookmark & { tags: Array<{ id: string; name: string; color: string | null }> } export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('bookmarks.read'), async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const query = url.searchParams.get('q') if (!query || query.trim().length === 0) { return badRequest('Search query is required') } const searchTerm = `%${query.trim()}%` const limit = Math.min(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 100) try { // Search bookmarks const { results: bookmarks } = await context.env.DB.prepare( `SELECT b.* FROM bookmarks b WHERE b.user_id = ? AND b.deleted_at IS NULL AND (b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?) ORDER BY b.is_pinned DESC, b.updated_at DESC LIMIT ?` ) .bind(userId, searchTerm, searchTerm, searchTerm, limit) .all() // Optimize: Use single query to get all bookmark tags let bookmarksWithTags: BookmarkWithTags[] = (bookmarks || []).map(bookmark => ({ ...bookmark, tags: [], })) if (bookmarksWithTags.length > 0) { const bookmarkIds = bookmarksWithTags.map(b => b.id) // Get all tags for bookmarks at once const { results: allTags } = await context.env.DB.prepare( `SELECT bt.bookmark_id, t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id IN (${bookmarkIds.map(() => '?').join(',')}) AND t.deleted_at IS NULL ORDER BY bt.bookmark_id, t.name` ) .bind(...bookmarkIds) .all<{ bookmark_id: string; id: string; name: string; color: string | null }>() // Group tags by bookmark ID const tagsByBookmarkId = new Map>() for (const tag of allTags || []) { if (!tagsByBookmarkId.has(tag.bookmark_id)) { tagsByBookmarkId.set(tag.bookmark_id, []) } const tags = tagsByBookmarkId.get(tag.bookmark_id) if (tags) { tags.push({ id: tag.id, name: tag.name, color: tag.color, }) } } // Assemble bookmarks with tags bookmarksWithTags = bookmarksWithTags.map(bookmark => ({ ...bookmark, tags: tagsByBookmarkId.get(bookmark.id) || [], })) } // Search tags const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color, t.created_at, t.updated_at, COUNT(bt.bookmark_id) as bookmark_count FROM tags t LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL WHERE t.user_id = ? AND t.deleted_at IS NULL AND t.name LIKE ? GROUP BY t.id ORDER BY t.name ASC LIMIT ?` ) .bind(userId, searchTerm, limit) .all() return success({ query, results: { bookmarks: bookmarksWithTags, tags: tags || [], }, meta: { bookmark_count: bookmarksWithTags.length, tag_count: (tags || []).length, }, }) } catch (error) { console.error('Search error:', error) return internalError('Failed to search') } }, ] ================================================ FILE: tmarks/functions/api/tab/statistics/index.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth' interface DomainCount { domain: string count: number } // GET /api/tab/statistics - Retrieve tab statistics export const onRequestGet: PagesFunction[] = [ requireDualAuth('tab_groups.read'), async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const days = parseInt(url.searchParams.get('days') || '30', 10) || 30 try { const startDate = new Date() startDate.setDate(startDate.getDate() - days) const startDateStr = startDate.toISOString().split('T')[0] const [ groupsResult, deletedGroupsResult, itemsResult, sharesResult, groupsTrend, itemsTrend, domains, groupSizes ] = await Promise.all([ context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 0' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 1' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( 'SELECT COUNT(*) as count FROM shares WHERE user_id = ?' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( `SELECT DATE(created_at) as date, COUNT(*) as count FROM tab_groups WHERE user_id = ? AND DATE(created_at) >= ? GROUP BY DATE(created_at) ORDER BY date ASC` ) .bind(userId, startDateStr) .all<{ date: string; count: number }>(), context.env.DB.prepare( `SELECT DATE(created_at) as date, COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) AND DATE(created_at) >= ? GROUP BY DATE(created_at) ORDER BY date ASC` ) .bind(userId, startDateStr) .all<{ date: string; count: number }>(), context.env.DB.prepare( `SELECT CASE WHEN url LIKE 'http://%' THEN SUBSTR(url, 8, INSTR(SUBSTR(url, 8), '/') - 1) WHEN url LIKE 'https://%' THEN SUBSTR(url, 9, INSTR(SUBSTR(url, 9), '/') - 1) ELSE url END as domain, COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) GROUP BY domain ORDER BY count DESC LIMIT 10` ) .bind(userId) .all(), context.env.DB.prepare( `SELECT CASE WHEN item_count = 0 THEN '0' WHEN item_count <= 5 THEN '1-5' WHEN item_count <= 10 THEN '6-10' WHEN item_count <= 20 THEN '11-20' WHEN item_count <= 50 THEN '21-50' ELSE '50+' END as range, COUNT(*) as count FROM ( SELECT g.id, COUNT(i.id) as item_count FROM tab_groups g LEFT JOIN tab_group_items i ON g.id = i.group_id WHERE g.user_id = ? AND g.is_deleted = 0 GROUP BY g.id ) GROUP BY range ORDER BY CASE range WHEN '0' THEN 1 WHEN '1-5' THEN 2 WHEN '6-10' THEN 3 WHEN '11-20' THEN 4 WHEN '21-50' THEN 5 ELSE 6 END` ) .bind(userId) .all<{ range: string; count: number }>() ]) return success({ summary: { total_groups: groupsResult.results?.[0]?.count || 0, total_deleted_groups: deletedGroupsResult.results?.[0]?.count || 0, total_items: itemsResult.results?.[0]?.count || 0, total_shares: sharesResult.results?.[0]?.count || 0, }, trends: { groups: groupsTrend.results || [], items: itemsTrend.results || [], }, top_domains: domains.results || [], group_size_distribution: groupSizes.results || [], }) } catch (error) { console.error('Get statistics error:', error) return internalError('Failed to get statistics') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/[id]/items/batch.ts ================================================ /** * API - * : /api/tab/tab-groups/:id/items/batch * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../../../middleware/dual-auth' import { sanitizeString } from '../../../../../lib/validation' import { generateUUID } from '../../../../../lib/crypto' interface TabGroupRow { id: string user_id: string title: string } interface BatchAddItemsRequest { items: Array<{ title: string url: string favicon?: string }> } // POST /api/tab/tab-groups/:id/items/batch - export const onRequestPost: PagesFunction[] = [ requireDualAuth('tab_groups.update'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { const body = (await context.request.json()) as BatchAddItemsRequest if (!body.items || !Array.isArray(body.items) || body.items.length === 0) { return badRequest('items array is required and must not be empty') } // const group = await context.env.DB.prepare( 'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } // position const maxPositionResult = await context.env.DB.prepare( 'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?' ) .bind(groupId) .first<{ max_position: number | null }>() let currentPosition = (maxPositionResult?.max_position ?? -1) + 1 // const timestamp = new Date().toISOString() const insertPromises = body.items.map((item) => { const itemId = generateUUID() const itemTitle = sanitizeString(item.title, 500) const itemUrl = sanitizeString(item.url, 2000) const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null const promise = context.env.DB.prepare( 'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ) .bind(itemId, groupId, itemTitle, itemUrl, favicon, currentPosition, timestamp) .run() currentPosition++ return promise }) await Promise.all(insertPromises) // (with user_id verification for security) const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY tgi.position ASC` ) .bind(groupId, userId) .all() return success({ message: `Successfully added ${body.items.length} items`, added_count: body.items.length, total_items: items?.length || 0, items: items || [], }) } catch (error) { console.error('Batch add items error:', error) return internalError('Failed to batch add items') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/[id]/permanent-delete.ts ================================================ /** * API * : /api/tab/tab-groups/:id/permanent-delete * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { notFound, internalError } from '../../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth' interface TabGroupRow { id: string user_id: string is_deleted: number } // DELETE /api/tab/tab-groups/:id/permanent-delete - export const onRequestDelete: PagesFunction[] = [ requireDualAuth('tab_groups.delete'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Check if tab group exists and is deleted const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 1' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found in trash') } // Delete tab group items first await context.env.DB.prepare('DELETE FROM tab_group_items WHERE group_id = ?') .bind(groupId) .run() // Delete shares await context.env.DB.prepare('DELETE FROM shares WHERE group_id = ?') .bind(groupId) .run() // Permanently delete tab group await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?') .bind(groupId) .run() return new Response(null, { status: 204 }) } catch (error) { console.error('Permanent delete tab group error:', error) return internalError('Failed to permanently delete tab group') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/[id]/restore.ts ================================================ /** * API * : /api/tab/tab-groups/:id/restore * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth' interface TabGroupRow { id: string user_id: string is_deleted: number } // POST /api/tab/tab-groups/:id/restore - export const onRequestPost: PagesFunction[] = [ requireDualAuth('tab_groups.update'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Check if tab group exists and is deleted const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 1' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found in trash') } // Restore tab group await context.env.DB.prepare( 'UPDATE tab_groups SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ?' ) .bind(new Date().toISOString(), groupId) .run() return success({ message: 'Tab group restored successfully' }) } catch (error) { console.error('Restore tab group error:', error) return internalError('Failed to restore tab group') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/[id]/share.ts ================================================ /** * API * : /api/tab/tab-groups/:id/share * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth' import { generateUUID } from '../../../../lib/crypto' interface TabGroupRow { id: string user_id: string is_deleted: number } interface ShareRow { id: string group_id: string user_id: string share_token: string is_public: number view_count: number created_at: string expires_at: string | null } interface CreateShareRequest { is_public?: boolean expires_in_days?: number } // POST /api/tab/tab-groups/:id/share - export const onRequestPost: PagesFunction[] = [ requireDualAuth('tab_groups.update'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { const body = (await context.request.json().catch(() => ({}))) as CreateShareRequest // Check if tab group exists and belongs to user const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 0' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found') } // Check if share already exists const existingShare = await context.env.DB.prepare( 'SELECT * FROM shares WHERE group_id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (existingShare) { return success({ share: existingShare, share_url: `${new URL(context.request.url).origin}/share/${existingShare.share_token}`, }) } // Generate share token const shareToken = generateUUID().replace(/-/g, '').substring(0, 16) const shareId = generateUUID() const now = new Date().toISOString() const isPublic = body.is_public !== false ? 1 : 0 let expiresAt: string | null = null if (body.expires_in_days && body.expires_in_days > 0) { const expiresDate = new Date() expiresDate.setDate(expiresDate.getDate() + body.expires_in_days) expiresAt = expiresDate.toISOString() } // Create share await context.env.DB.prepare( 'INSERT INTO shares (id, group_id, user_id, share_token, is_public, view_count, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ) .bind(shareId, groupId, userId, shareToken, isPublic, 0, now, expiresAt) .run() const share = { id: shareId, group_id: groupId, user_id: userId, share_token: shareToken, is_public: isPublic, view_count: 0, created_at: now, expires_at: expiresAt, } return success({ share, share_url: `${new URL(context.request.url).origin}/share/${shareToken}`, }) } catch (error) { console.error('Create share error:', error) return internalError('Failed to create share') } }, ] // GET /api/tab/tab-groups/:id/share - export const onRequestGet: PagesFunction[] = [ requireDualAuth('tab_groups.read'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Get share const share = await context.env.DB.prepare( 'SELECT * FROM shares WHERE group_id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!share) { return notFound('Share not found') } return success({ share, share_url: `${new URL(context.request.url).origin}/share/${share.share_token}`, }) } catch (error) { console.error('Get share error:', error) return internalError('Failed to get share') } }, ] // DELETE /api/tab/tab-groups/:id/share - export const onRequestDelete: PagesFunction[] = [ requireDualAuth('tab_groups.delete'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Delete share await context.env.DB.prepare('DELETE FROM shares WHERE group_id = ? AND user_id = ?') .bind(groupId, userId) .run() return new Response(null, { status: 204 }) } catch (error) { console.error('Delete share error:', error) return internalError('Failed to delete share') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/[id].ts ================================================ /** * API - * : /api/tab/tab-groups/:id * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth' import { sanitizeString } from '../../../lib/validation' interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null parent_id: string | null is_folder: number is_deleted: number deleted_at: string | null position: number created_at: string updated_at: string } interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string } interface UpdateTabGroupRequest { title?: string color?: string | null tags?: string[] | null parent_id?: string | null position?: number } // GET /api/tab/tab-groups/:id - export const onRequestGet: PagesFunction[] = [ requireDualAuth('tab_groups.read'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Get tab group (exclude deleted by default) let groupRow: TabGroupRow | null = null try { groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)' ) .bind(groupId, userId) .first() } catch { // Fallback: query without is_deleted column groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() } if (!groupRow) { return notFound('Tab group not found') } // Parse tags if exists let tags: string[] | null = null if (groupRow.tags) { try { tags = JSON.parse(groupRow.tags) } catch { tags = null } } // Get tab group items (with user_id verification for security) const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC` ) .bind(groupId, userId) .all() return success({ tab_group: { ...groupRow, tags, items: items || [], item_count: items?.length || 0, }, }) } catch (error) { console.error('Get tab group error:', error) return internalError('Failed to get tab group') } }, ] // PATCH /api/tab/tab-groups/:id - export const onRequestPatch: PagesFunction[] = [ requireDualAuth('tab_groups.update'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { let body: UpdateTabGroupRequest try { body = (await context.request.json()) as UpdateTabGroupRequest } catch (parseError) { console.error('Failed to parse request body:', parseError) return badRequest('Invalid request body: ' + (parseError instanceof Error ? parseError.message : 'JSON parse error')) } // Check if tab group exists and belongs to user const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found') } // Update tab group const updates: string[] = [] const params: (string | number | null)[] = [] if (body.title !== undefined) { updates.push('title = ?') params.push(sanitizeString(body.title, 200)) } // Only add color/tags if they exist in the request // Try to update, if column doesn't exist, skip silently let hasColorOrTags = false if (body.color !== undefined) { updates.push('color = ?') params.push(body.color) hasColorOrTags = true } if (body.tags !== undefined) { updates.push('tags = ?') params.push(body.tags ? JSON.stringify(body.tags) : null) hasColorOrTags = true } if (body.parent_id !== undefined) { updates.push('parent_id = ?') params.push(body.parent_id) } if (body.position !== undefined) { updates.push('position = ?') params.push(body.position) } if (updates.length === 0) { return badRequest('No fields to update') } updates.push('updated_at = ?') params.push(new Date().toISOString()) params.push(groupId) // Add user_id to WHERE clause params params.push(userId) try { await context.env.DB.prepare( `UPDATE tab_groups SET ${updates.join(', ')} WHERE id = ? AND user_id = ?` ) .bind(...params) .run() } catch (e) { // If update fails (likely due to missing columns), try without color/tags if (hasColorOrTags && body.title !== undefined) { await context.env.DB.prepare( 'UPDATE tab_groups SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?' ) .bind(sanitizeString(body.title, 200), new Date().toISOString(), groupId, userId) .run() } else { throw e } } // Get updated tab group with items const updatedGroup = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ?' ) .bind(groupId) .first() const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY tgi.position ASC` ) .bind(groupId, userId) .all() if (!updatedGroup) { return internalError('Failed to load tab group after update') } return success({ tab_group: { ...updatedGroup, items: items || [], item_count: items?.length || 0, }, }) } catch (error) { console.error('Update tab group error:', error) return internalError('Failed to update tab group') } }, ] // DELETE /api/tab/tab-groups/:id - () export const onRequestDelete: PagesFunction[] = [ requireDualAuth('tab_groups.delete'), async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Check if tab group exists and belongs to user let groupRow: TabGroupRow | null = null try { groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)' ) .bind(groupId, userId) .first() } catch { // Fallback: query without is_deleted column groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() } if (!groupRow) { return notFound('Tab group not found') } // Soft delete - mark as deleted (only if column exists) try { await context.env.DB.prepare( 'UPDATE tab_groups SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?' ) .bind(new Date().toISOString(), new Date().toISOString(), groupId) .run() } catch { // If is_deleted column doesn't exist, do hard delete await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?') .bind(groupId) .run() } return new Response(null, { status: 204 }) } catch (error) { console.error('Delete tab group error:', error) return internalError('Failed to delete tab group') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/index.ts ================================================ /** * API - * : /api/tab/tab-groups */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams, SQLParam } from '../../../lib/types' import { success, created, internalError } from '../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth' import { sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null parent_id: string | null is_folder: number is_deleted: number deleted_at: string | null position: number created_at: string updated_at: string } interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string } interface CreateTabGroupRequest { title?: string parent_id?: string | null is_folder?: boolean items?: Array<{ title: string url: string favicon?: string }> } function parseTags(group: TabGroupRow): string[] | null { if (!group.tags) return null try { return JSON.parse(group.tags) } catch { return null } } // GET /api/tab/tab-groups export const onRequestGet: PagesFunction[] = [ requireDualAuth('tab_groups.read'), async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100) const pageCursor = url.searchParams.get('page_cursor') || '' try { let groups: TabGroupRow[] = [] try { let query = ` SELECT * FROM tab_groups WHERE user_id = ? AND (is_deleted IS NULL OR is_deleted = 0) ` const params: SQLParam[] = [userId] if (pageCursor) { query += ` AND created_at < ?` params.push(pageCursor) } query += ` ORDER BY created_at DESC LIMIT ?` params.push(pageSize + 1) const result = await context.env.DB.prepare(query) .bind(...params) .all() groups = result.results } catch { let query = ` SELECT * FROM tab_groups WHERE user_id = ? ` const params: SQLParam[] = [userId] if (pageCursor) { query += ` AND created_at < ?` params.push(pageCursor) } query += ` ORDER BY created_at DESC LIMIT ?` params.push(pageSize + 1) const result = await context.env.DB.prepare(query) .bind(...params) .all() groups = result.results } const hasMore = groups.length > pageSize const tabGroups = hasMore ? groups.slice(0, pageSize) : groups const nextCursor = hasMore ? tabGroups[tabGroups.length - 1].created_at : undefined // Batch fetch all items (avoids N+1) const groupIds = tabGroups.map((g) => g.id) let allItems: TabGroupItemRow[] = [] if (groupIds.length > 0) { const placeholders = groupIds.map(() => '?').join(',') const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id IN (${placeholders}) AND tg.user_id = ? ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC` ) .bind(...groupIds, userId) .all() allItems = items || [] } const itemsByGroup = new Map() for (const item of allItems) { const arr = itemsByGroup.get(item.group_id) || [] arr.push(item) itemsByGroup.set(item.group_id, arr) } const groupsWithItems = tabGroups.map((group) => { const items = itemsByGroup.get(group.id) || [] return { ...group, tags: parseTags(group), items, item_count: items.length, } }) return success({ tab_groups: groupsWithItems, meta: { page_size: pageSize, next_cursor: nextCursor, }, }) } catch (error) { console.error('Get tab groups error:', error) return internalError('Failed to get tab groups') } }, ] // POST /api/tab/tab-groups export const onRequestPost: PagesFunction[] = [ requireDualAuth('tab_groups.create'), async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as CreateTabGroupRequest const isFolder = body.is_folder || false const now = new Date() const defaultTitle = body.title || (isFolder ? '' : now.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).replace(/\//g, '-')) const title = sanitizeString(defaultTitle, 200) const groupId = generateUUID() const timestamp = now.toISOString() const parentId = body.parent_id || null // Atomic batch: group + all items const stmts = [ context.env.DB.prepare( 'INSERT INTO tab_groups (id, user_id, title, parent_id, is_folder, is_deleted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)' ).bind(groupId, userId, title, parentId, isFolder ? 1 : 0, timestamp, timestamp), ] if (!isFolder && body.items && body.items.length > 0) { for (let i = 0; i < body.items.length; i++) { const item = body.items[i] const itemId = generateUUID() const itemTitle = sanitizeString(item.title, 500) const itemUrl = sanitizeString(item.url, 2000) const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null stmts.push( context.env.DB.prepare( 'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ).bind(itemId, groupId, itemTitle, itemUrl, favicon, i, timestamp) ) } } await context.env.DB.batch(stmts) const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!groupRow) { return internalError('Failed to load tab group after creation') } const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY tgi.position ASC` ) .bind(groupId, userId) .all() return created({ tab_group: { ...groupRow, items: items || [], item_count: items?.length || 0, }, }) } catch (error) { console.error('Create tab group error:', error) return internalError('Failed to create tab group') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/items/[id]/move.ts ================================================ /** * API - * : /api/tab/tab-groups/items/:id/move * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../../../middleware/dual-auth' interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string is_pinned?: number is_todo?: number } interface MoveItemRequest { target_group_id: string position?: number } // POST /api/tab/tab-groups/items/:id/move - export const onRequestPost: PagesFunction[] = [ requireDualAuth('tab_groups.update'), async (context) => { const userId = context.data.user_id const itemId = context.params.id try { const body = (await context.request.json()) as MoveItemRequest if (!body.target_group_id) { return badRequest('target_group_id is required') } // 1. const item = await context.env.DB.prepare( `SELECT tgi.*, tg.user_id FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ?` ) .bind(itemId) .first() if (!item) { return notFound('Tab group item not found') } if (item.user_id !== userId) { return notFound('Tab group item not found') } // 2. const targetGroup = await context.env.DB.prepare( 'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(body.target_group_id, userId) .first<{ id: string; user_id: string }>() if (!targetGroup) { return badRequest('Target group not found or access denied') } // 3. , if (item.group_id === body.target_group_id) { if (body.position !== undefined) { // await context.env.DB.prepare( 'UPDATE tab_group_items SET position = ? WHERE id = ?' ) .bind(body.position, itemId) .run() // await context.env.DB.prepare( `UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ? AND position >= ?` ) .bind(item.group_id, itemId, body.position) .run() } } else { // 4. // 4.1 const maxPositionResult = await context.env.DB.prepare( 'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?' ) .bind(body.target_group_id) .first<{ max_position: number | null }>() const targetPosition = body.position !== undefined ? body.position : (maxPositionResult?.max_position ?? -1) + 1 // 4.2 await context.env.DB.prepare( `UPDATE tab_group_items SET group_id = ?, position = ? WHERE id = ?` ) .bind(body.target_group_id, targetPosition, itemId) .run() // 4.3 () await context.env.DB.prepare( `UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?` ) .bind(item.group_id, item.position) .run() // 4.4 () if (body.position !== undefined) { await context.env.DB.prepare( `UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ? AND position >= ?` ) .bind(body.target_group_id, itemId, targetPosition) .run() } } // 5. const updatedItem = await context.env.DB.prepare( 'SELECT * FROM tab_group_items WHERE id = ?' ) .bind(itemId) .first() if (!updatedItem) { return internalError('Failed to load item after move') } return success({ item: updatedItem, message: 'Item moved successfully', }) } catch (error) { console.error('Move tab group item error:', error) return internalError('Failed to move tab group item') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/items/[id].ts ================================================ /** * API - * : /api/tab/tab-groups/items/:id * : API Key (X-API-Key header) JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth' import { sanitizeString } from '../../../../lib/validation' interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string is_pinned?: number is_todo?: number is_archived?: number } interface UpdateTabGroupItemRequest { title?: string is_pinned?: boolean is_todo?: boolean is_archived?: boolean position?: number } // PATCH /api/tab/tab-groups/items/:id - export const onRequestPatch: PagesFunction[] = [ requireDualAuth('tab_groups.update'), async (context) => { const userId = context.data.user_id const itemId = context.params.id try { const body = (await context.request.json()) as UpdateTabGroupItemRequest // Check if item exists and user has permission const item = await context.env.DB.prepare( `SELECT tgi.*, tg.user_id FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ?` ) .bind(itemId) .first() if (!item) { return notFound('Tab group item not found') } if (item.user_id !== userId) { return notFound('Tab group item not found') } // Build update query const updates: string[] = [] const params: (string | number)[] = [] if (body.title !== undefined) { updates.push('title = ?') params.push(sanitizeString(body.title, 500)) } if (body.is_pinned !== undefined) { updates.push('is_pinned = ?') params.push(body.is_pinned ? 1 : 0) // If pinning, set position to 0 and shift others if (body.is_pinned) { await context.env.DB.prepare( 'UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ?' ) .bind(item.group_id, itemId) .run() updates.push('position = ?') params.push(0) } } if (body.is_todo !== undefined) { updates.push('is_todo = ?') params.push(body.is_todo ? 1 : 0) } if (body.is_archived !== undefined) { updates.push('is_archived = ?') params.push(body.is_archived ? 1 : 0) } if (body.position !== undefined) { updates.push('position = ?') params.push(body.position) } if (updates.length === 0) { return badRequest('No fields to update') } params.push(itemId, item.group_id, userId) await context.env.DB.prepare( `UPDATE tab_group_items SET ${updates.join(', ')} WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)` ) .bind(...params) .run() // Get updated item const updatedItem = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ? AND tg.user_id = ?` ) .bind(itemId, userId) .first() if (!updatedItem) { return internalError('Failed to load item after update') } return success({ item: updatedItem, }) } catch (error) { console.error('Update tab group item error:', error) return internalError('Failed to update tab group item') } }, ] // DELETE /api/tab/tab-groups/items/:id - export const onRequestDelete: PagesFunction[] = [ requireDualAuth('tab_groups.delete'), async (context) => { const userId = context.data.user_id const itemId = context.params.id try { // Check if item exists and user has permission const item = await context.env.DB.prepare( `SELECT tgi.*, tg.user_id FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ?` ) .bind(itemId) .first() if (!item) { return notFound('Tab group item not found') } if (item.user_id !== userId) { return notFound('Tab group item not found') } // Delete item with ownership check await context.env.DB.prepare( 'DELETE FROM tab_group_items WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)' ) .bind(itemId, item.group_id, userId) .run() // Reorder remaining items await context.env.DB.prepare( 'UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?' ) .bind(item.group_id, item.position) .run() return new Response(null, { status: 204 }) } catch (error) { console.error('Delete tab group item error:', error) return internalError('Failed to delete tab group item') } }, ] ================================================ FILE: tmarks/functions/api/tab/tab-groups/trash.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth' interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null is_deleted: number deleted_at: string | null created_at: string updated_at: string } // GET /api/tab/tab-groups/trash - Retrieve trashed tab groups export const onRequestGet: PagesFunction[] = [ requireDualAuth('tab_groups.read'), async (context) => { const userId = context.data.user_id try { const { results: groups } = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE user_id = ? AND is_deleted = 1 ORDER BY deleted_at DESC' ) .bind(userId) .all() const groupsWithCounts = await Promise.all( (groups || []).map(async (group) => { const { results: items } = await context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id = ?' ) .bind(group.id) .all<{ count: number }>() let tags: string[] | null = null if (group.tags) { try { tags = JSON.parse(group.tags) } catch { tags = null } } return { ...group, tags, item_count: items?.[0]?.count || 0, } }) ) return success({ tab_groups: groupsWithCounts, total: groupsWithCounts.length, }) } catch (error) { console.error('Get trash error:', error) return internalError('Failed to get trash') } }, ] ================================================ FILE: tmarks/functions/api/tab/tags/[id]/click.ts ================================================ /** * API - * : /api/tab/tags/:id/click * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages' // PATCH /api/tags/:id/click - export const onRequestPatch: PagesFunction[] = [ requireApiKeyAuth('tags.update'), async (context) => { const userId = context.data.user_id const tagId = context.params.id try { // const existing = await context.env.DB.prepare( 'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(tagId, userId) .first() if (!existing) { return notFound('Tag not found') } const now = new Date().toISOString() // await context.env.DB.prepare( `UPDATE tags SET click_count = click_count + 1, last_clicked_at = ?, updated_at = ? WHERE id = ? AND user_id = ?` ) .bind(now, now, tagId, userId) .run() return success({ message: 'Click count incremented' }) } catch (error) { console.error('Increment tag click count error:', error) return internalError('Failed to increment click count') } }, ] ================================================ FILE: tmarks/functions/api/tab/tags/[id].ts ================================================ /** * API - * : /api/tab/tags/:id * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams, SQLParam } from '../../../lib/types' import { success, badRequest, notFound, noContent, internalError } from '../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' import { sanitizeString } from '../../../lib/validation' interface UpdateTagRequest { name?: string color?: string } // GET /api/tags/:id - export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('tags.read'), async (context) => { const userId = context.data.user_id const tagId = context.params.id try { const tag = await context.env.DB.prepare( `SELECT t.id, t.name, t.color, t.created_at, t.updated_at, COUNT(bt.bookmark_id) as bookmark_count FROM tags t LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL WHERE t.id = ? AND t.user_id = ? AND t.deleted_at IS NULL GROUP BY t.id` ) .bind(tagId, userId) .first() if (!tag) { return notFound('Tag not found') } return success({ tag }) } catch (error) { console.error('Get tag error:', error) return internalError('Failed to get tag') } }, ] // PATCH /api/tags/:id - export const onRequestPatch: PagesFunction[] = [ requireApiKeyAuth('tags.update'), async (context) => { const userId = context.data.user_id const tagId = context.params.id try { const existing = await context.env.DB.prepare( 'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(tagId, userId) .first() if (!existing) { return notFound('Tag not found') } const body = (await context.request.json()) as UpdateTagRequest const updates: string[] = [] const values: SQLParam[] = [] // if (body.name !== undefined) { if (!body.name.trim()) { return badRequest('Tag name cannot be empty') } const name = sanitizeString(body.name, 50) // const duplicate = await context.env.DB.prepare( 'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL' ) .bind(userId, name, tagId) .first() if (duplicate) { return badRequest('Tag with this name already exists') } updates.push('name = ?') values.push(name) } // if (body.color !== undefined) { updates.push('color = ?') values.push(body.color ? sanitizeString(body.color, 20) : null) } if (updates.length === 0) { return badRequest('No fields to update') } const now = new Date().toISOString() updates.push('updated_at = ?') values.push(now) values.push(tagId, userId) await context.env.DB.prepare( `UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND user_id = ?` ) .bind(...values) .run() const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?') .bind(tagId, userId) .first() return success({ tag }) } catch (error) { console.error('Update tag error:', error) return internalError('Failed to update tag') } }, ] // DELETE /api/tags/:id - export const onRequestDelete: PagesFunction[] = [ requireApiKeyAuth('tags.delete'), async (context) => { const userId = context.data.user_id const tagId = context.params.id try { const existing = await context.env.DB.prepare( 'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(tagId, userId) .first() if (!existing) { return notFound('Tag not found') } const now = new Date().toISOString() // await context.env.DB.prepare( 'UPDATE tags SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?' ) .bind(now, now, tagId, userId) .run() await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE tag_id = ? AND user_id = ?') .bind(tagId, userId) .run() return noContent() } catch (error) { console.error('Delete tag error:', error) return internalError('Failed to delete tag') } }, ] ================================================ FILE: tmarks/functions/api/tab/tags/index.ts ================================================ /** * API - * : /api/tab/tags * : API Key (X-API-Key header) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { success, badRequest, created, internalError } from '../../../lib/response' import { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages' import { sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' interface CreateTagRequest { name: string color?: string } interface TagWithCount { id: string name: string color: string | null bookmark_count: number created_at: string updated_at: string } // GET /api/tags - export const onRequestGet: PagesFunction[] = [ requireApiKeyAuth('tags.read'), async (context) => { const userId = context.data.user_id try { const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color, t.created_at, t.updated_at, COUNT(bt.bookmark_id) as bookmark_count FROM tags t LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL WHERE t.user_id = ? AND t.deleted_at IS NULL GROUP BY t.id ORDER BY t.name ASC` ) .bind(userId) .all() return success({ tags: tags || [] }) } catch (error) { console.error('Get tags error:', error) return internalError('Failed to get tags') } }, ] // POST /api/tags - export const onRequestPost: PagesFunction[] = [ requireApiKeyAuth('tags.create'), async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as CreateTagRequest if (!body.name || !body.name.trim()) { return badRequest('Tag name is required') } const name = sanitizeString(body.name, 50) const color = body.color ? sanitizeString(body.color, 20) : null // const existing = await context.env.DB.prepare( 'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND deleted_at IS NULL' ) .bind(userId, name) .first() if (existing) { return badRequest('Tag with this name already exists') } const now = new Date().toISOString() const tagId = generateUUID() await context.env.DB.prepare( `INSERT INTO tags (id, user_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` ) .bind(tagId, userId, name, color, now, now) .run() const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ?') .bind(tagId) .first() return created({ tag }) } catch (error) { console.error('Create tag error:', error) return internalError('Failed to create tag') } }, ] ================================================ FILE: tmarks/functions/api/v1/auth/login.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, User } from '../../../lib/types' import { badRequest, unauthorized, success, internalError } from '../../../lib/response' import { verifyPassword, generateToken, hashRefreshToken, generateUUID } from '../../../lib/crypto' import { generateJWT, parseExpiry } from '../../../lib/jwt' import { loginRateLimiter } from '../../../lib/rate-limit' import { getJwtAccessTokenExpiresIn, getJwtRefreshTokenExpiresIn } from '../../../lib/config' interface LoginRequest { username: string password: string remember_me?: boolean } export const onRequestPost: PagesFunction[] = [ loginRateLimiter, async (context) => { try { const body = await context.request.json() as LoginRequest if (!body.username || !body.password) { return badRequest('Username and password are required') } // () type DbUser = User & { role?: string | null } let user: DbUser | null = null try { user = await context.env.DB.prepare( `SELECT id, username, email, password_hash, role FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)` ) .bind(body.username, body.username) .first() } catch (error) { if (error instanceof Error && /no such column: role/i.test(error.message)) { user = await context.env.DB.prepare( `SELECT id, username, email, password_hash FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)` ) .bind(body.username, body.username) .first() } else { throw error } } if (!user) { const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' await context.env.DB.prepare( `INSERT INTO audit_logs (event_type, payload, ip, created_at) VALUES ('auth.login_failed', ?, ?, ?)` ) .bind( JSON.stringify({ username: body.username, reason: 'user_not_found' }), ip, new Date().toISOString() ) .run() return unauthorized('Invalid username or password') } // const isValid = await verifyPassword(body.password, user.password_hash) if (!isValid) { const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' await context.env.DB.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at) VALUES (?, 'auth.login_failed', ?, ?, ?)` ) .bind( user.id, JSON.stringify({ username: body.username, reason: 'invalid_password' }), ip, new Date().toISOString() ) .run() return unauthorized('Invalid username or password') } // session_id const sessionId = generateUUID() const role = user.role ?? 'user' // () const accessTokenExpiresInStr = getJwtAccessTokenExpiresIn(context.env) const accessTokenExpiresIn = parseExpiry(accessTokenExpiresInStr) const accessToken = await generateJWT( { sub: user.id, session_id: sessionId }, context.env.JWT_SECRET, accessTokenExpiresInStr ) // const refreshToken = generateToken(32) const refreshTokenHash = await hashRefreshToken(refreshToken) // const refreshTokenExpiresInStr = getJwtRefreshTokenExpiresIn(context.env) const refreshTokenExpiresIn = parseExpiry(refreshTokenExpiresInStr) const refreshTokenExpiresAt = new Date(Date.now() + refreshTokenExpiresIn * 1000) // await context.env.DB.prepare( `INSERT INTO auth_tokens (user_id, refresh_token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)` ) .bind(user.id, refreshTokenHash, refreshTokenExpiresAt.toISOString(), new Date().toISOString()) .run() const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' const userAgent = context.request.headers.get('User-Agent') || 'unknown' await context.env.DB.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, ip, user_agent, created_at) VALUES (?, 'auth.login_success', ?, ?, ?, ?)` ) .bind( user.id, JSON.stringify({ session_id: sessionId, remember_me: body.remember_me }), ip, userAgent, new Date().toISOString() ) .run() return success({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: accessTokenExpiresIn, user: { id: user.id, username: user.username, email: user.email, role, }, }) } catch (error) { console.error('Login error:', error) return internalError('Login failed') } }, ] ================================================ FILE: tmarks/functions/api/v1/auth/logout.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { badRequest, noContent, internalError } from '../../../lib/response' import { hashRefreshToken } from '../../../lib/crypto' import { requireAuth, AuthContext } from '../../../middleware/auth' interface LogoutRequest { refresh_token: string revoke_all?: boolean // } export const onRequest: PagesFunction[] = [ requireAuth, async (context) => { try { const body = await context.request.json() as LogoutRequest if (!body.refresh_token) { return badRequest('Refresh token is required') } const userId = context.data.user_id const now = new Date().toISOString() if (body.revoke_all) { await context.env.DB.prepare( `UPDATE auth_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL` ) .bind(now, userId) .run() // const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' await context.env.DB.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at) VALUES (?, 'auth.logout_all_devices', ?, ?, ?)` ) .bind(userId, JSON.stringify({ revoked_count: 'all' }), ip, now) .run() } else { // const tokenHash = await hashRefreshToken(body.refresh_token) await context.env.DB.prepare( `UPDATE auth_tokens SET revoked_at = ? WHERE refresh_token_hash = ? AND user_id = ? AND revoked_at IS NULL` ) .bind(now, tokenHash, userId) .run() // const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' await context.env.DB.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at) VALUES (?, 'auth.logout', ?, ?, ?)` ) .bind(userId, JSON.stringify({ single_device: true }), ip, now) .run() } return noContent() } catch (error) { console.error('Logout error:', error) return internalError('Logout failed') } }, ] ================================================ FILE: tmarks/functions/api/v1/auth/refresh.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { badRequest, unauthorized, success, internalError } from '../../../lib/response' import { hashRefreshToken, generateUUID } from '../../../lib/crypto' import { generateJWT } from '../../../lib/jwt' import { getJwtAccessTokenExpiresIn } from '../../../lib/config' interface RefreshRequest { refresh_token: string } export const onRequestPost: PagesFunction = async (context) => { try { const body = await context.request.json() as RefreshRequest if (!body.refresh_token) { return badRequest('Refresh token is required') } // const tokenHash = await hashRefreshToken(body.refresh_token) // const tokenRecord = await context.env.DB.prepare( `SELECT id, user_id, expires_at, revoked_at FROM auth_tokens WHERE refresh_token_hash = ?` ) .bind(tokenHash) .first<{ id: number user_id: string expires_at: string revoked_at: string | null }>() if (!tokenRecord) { return unauthorized('Invalid refresh token') } // if (tokenRecord.revoked_at) { return unauthorized('Refresh token has been revoked') } const expiresAt = new Date(tokenRecord.expires_at) if (expiresAt < new Date()) { return unauthorized('Refresh token has expired') } // session_id const sessionId = generateUUID() // const accessToken = await generateJWT( { sub: tokenRecord.user_id, session_id: sessionId }, context.env.JWT_SECRET, getJwtAccessTokenExpiresIn(context.env) ) // type DbUser = { id: string; username: string; email: string | null; role?: string | null } let user: DbUser | null = null try { user = await context.env.DB.prepare( 'SELECT id, username, email, role FROM users WHERE id = ?' ) .bind(tokenRecord.user_id) .first() } catch (error) { if (error instanceof Error && /no such column: role/i.test(error.message)) { user = await context.env.DB.prepare( 'SELECT id, username, email FROM users WHERE id = ?' ) .bind(tokenRecord.user_id) .first() } else { throw error } } if (!user) { return unauthorized('User not found') } const role = user.role ?? 'user' // const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' await context.env.DB.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at) VALUES (?, 'auth.token_refreshed', ?, ?, ?)` ) .bind( tokenRecord.user_id, JSON.stringify({ session_id: sessionId }), ip, new Date().toISOString() ) .run() return success({ access_token: accessToken, token_type: 'Bearer', expires_in: parseExpiresInToSeconds(getJwtAccessTokenExpiresIn(context.env)), user: { id: user.id, username: user.username, email: user.email, role, }, }) } catch (error) { console.error('Refresh error:', error) return internalError('Token refresh failed') } } function parseExpiresInToSeconds(expiresIn: string): number { const match = expiresIn.match(/^(\d+)(s|m|h|d)$/) if (!match) return 31536000 const value = parseInt(match[1], 10) switch (match[2]) { case 's': return value case 'm': return value * 60 case 'h': return value * 3600 case 'd': return value * 86400 default: return 31536000 } } ================================================ FILE: tmarks/functions/api/v1/auth/register.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { badRequest, created, conflict, internalError } from '../../../lib/response' import { isValidUsername, isValidPassword, isValidEmail, sanitizeString } from '../../../lib/validation' import { hashPassword, generateUUID } from '../../../lib/crypto' interface RegisterRequest { username: string password: string email?: string } export const onRequestPost: PagesFunction = async (context) => { try { const db = context.env.DB // Rate limiting: Max 5 registration attempts per IP per hour const clientIP = context.request.headers.get('CF-Connecting-IP') || 'unknown' // Log registration attempt (ignore errors) try { await db.prepare( `INSERT INTO audit_logs (user_id, event_type, ip, payload, created_at) VALUES ('system', 'register_attempt', ?, ?, datetime('now'))` ).bind(clientIP, JSON.stringify({ ip: clientIP })).run() } catch { // Ignore audit log errors } const rateCheck = await db.prepare( `SELECT COUNT(*) as cnt FROM audit_logs WHERE event_type = 'register_attempt' AND ip = ? AND created_at > datetime('now', '-1 hour')` ).bind(clientIP).first<{ cnt: number }>() if (rateCheck && rateCheck.cnt >= 5) { return new Response(JSON.stringify({ code: 'RATE_LIMITED', message: 'Too many registration attempts' }), { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '3600' } }) } // Check if registration is enabled if (context.env.ALLOW_REGISTRATION !== 'true') { return badRequest('Registration is currently disabled') } const body = await context.request.json() as RegisterRequest // if (!body.username || !body.password) { return badRequest('Username and password are required') } if (!isValidUsername(body.username)) { return badRequest('Username must be 3-20 characters and contain only letters, numbers, and underscores') } if (!isValidPassword(body.password)) { return badRequest('Password must be at least 8 characters') } if (body.email && !isValidEmail(body.email)) { return badRequest('Invalid email format') } const username = sanitizeString(body.username, 20) const email = body.email ? sanitizeString(body.email, 255) : null // Check if username exists const existingUser = await db.prepare( 'SELECT id FROM users WHERE LOWER(username) = LOWER(?)' ) .bind(username) .first() if (existingUser) { return conflict('Username already exists') } // Check if email exists if (email) { const existingEmail = await db.prepare( 'SELECT id FROM users WHERE LOWER(email) = LOWER(?)' ) .bind(email) .first() if (existingEmail) { return conflict('Email already exists') } } // Hash password const passwordHash = await hashPassword(body.password) // Generate UUID const userId = generateUUID() const now = new Date() const nowISO = now.toISOString() const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' const userAgent = context.request.headers.get('User-Agent') || 'unknown' // Create user await db.prepare( `INSERT INTO users (id, username, email, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` ) .bind(userId, username, email, passwordHash, nowISO, nowISO) .run() // Create default preferences try { await db.prepare( `INSERT INTO user_preferences (user_id, theme, page_size, view_mode, density, tag_layout, sort_by, updated_at) VALUES (?, 'light', 30, 'list', 'normal', 'grid', 'popular', ?)` ) .bind(userId, nowISO) .run() } catch (error) { if (error instanceof Error && (/no such column: tag_layout/i.test(error.message) || /no such column: sort_by/i.test(error.message))) { // Fallback for older schema without tag_layout and sort_by await db.prepare( `INSERT INTO user_preferences (user_id, theme, page_size, view_mode, density, updated_at) VALUES (?, 'light', 30, 'list', 'normal', ?)` ) .bind(userId, nowISO) .run() } else { throw error } } // Log registration (ignore errors) try { await db.prepare( `INSERT INTO audit_logs (user_id, event_type, payload, ip, user_agent, created_at) VALUES (?, 'user.registered', ?, ?, ?, ?)` ) .bind( userId, JSON.stringify({ username, email: email || null }), ip, userAgent, nowISO ) .run() } catch (auditError) { console.error('Failed to create audit log:', auditError) } return created({ user: { id: userId, username, email: email || null, created_at: nowISO, }, }) } catch (error) { console.error('Register error:', error) return internalError('Registration failed') } } ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/click.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' // POST /api/v1/bookmarks/:id/click - export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const bookmarkId = context.params.id as string const now = new Date().toISOString() const db = context.env.DB // const bookmark = await db.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // , await db.batch([ db.prepare( 'UPDATE bookmarks SET click_count = click_count + 1, last_clicked_at = ? WHERE id = ?' ).bind(now, bookmarkId), db.prepare( 'INSERT INTO bookmark_click_events (bookmark_id, user_id, clicked_at) VALUES (?, ?, ?)' ).bind(bookmarkId, userId, now), ]) return success({ message: 'Click recorded successfully', clicked_at: now, }) } catch (error) { console.error('Record click error:', error) return internalError('Failed to record click') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/permanent.ts ================================================ /** * API * : /api/v1/bookmarks/:id/permanent * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { noContent, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' // DELETE /api/v1/bookmarks/:id/permanent - () export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { // const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found in trash') } // await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?') .bind(bookmarkId) .run() // await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?') .bind(bookmarkId) .run() // await context.env.DB.prepare('DELETE FROM bookmarks WHERE id = ?') .bind(bookmarkId) .run() return noContent() } catch (error) { console.error('Permanent delete bookmark error:', error) return internalError('Failed to permanently delete bookmark') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/restore.ts ================================================ /** * API * : /api/v1/bookmarks/:id/restore * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' import { normalizeBookmark } from '../../../../lib/bookmark-utils' // PATCH /api/v1/bookmarks/:id/restore - export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { // const existing = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) .bind(bookmarkId, userId) .first() if (!existing) { return notFound('Bookmark not found in trash') } const now = new Date().toISOString() // : deleted_at await context.env.DB.prepare( 'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ?' ) .bind(now, bookmarkId) .run() // const bookmarkRow = await context.env.DB.prepare( 'SELECT * FROM bookmarks WHERE id = ?' ) .bind(bookmarkId) .first() if (!bookmarkRow) { return internalError('Failed to load bookmark after restore') } // const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL` ) .bind(bookmarkId) .all<{ id: string; name: string; color: string | null }>() return success({ bookmark: { ...normalizeBookmark(bookmarkRow), tags: tags || [], }, }) } catch (error) { console.error('Restore bookmark error:', error) return internalError('Failed to restore bookmark') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshot-cleanup.ts ================================================ /** * — */ export async function cleanupOldSnapshots( db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string ) { try { const bookmarkSettings = await db .prepare('SELECT snapshot_retention_count FROM bookmarks WHERE id = ? AND user_id = ?') .bind(bookmarkId, userId) .first() const userSettings = await db .prepare('SELECT snapshot_retention_count FROM user_preferences WHERE user_id = ?') .bind(userId) .first() const retentionCount = (bookmarkSettings?.snapshot_retention_count as number | null) ?? (userSettings?.snapshot_retention_count as number | null) ?? 5 if (retentionCount === -1) { return } const toDelete = await db .prepare( `SELECT id, r2_key FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC LIMIT -1 OFFSET ?` ) .bind(bookmarkId, userId, retentionCount) .all() if (!toDelete.results || toDelete.results.length === 0) { return } const deletedIds: unknown[] = [] for (const snapshot of toDelete.results) { try { await bucket.delete(snapshot.r2_key as string) deletedIds.push(snapshot.id) } catch (error) { console.error('Failed to delete R2 file:', snapshot.r2_key, error) } } if (deletedIds.length === 0) return const placeholders = deletedIds.map(() => '?').join(',') await db .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`) .bind(...deletedIds, userId) .run() } catch (error) { console.error('Cleanup snapshots error:', error) } } ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshots/[snapshotId]/view.ts ================================================ /** * API - URL * : /api/v1/bookmarks/:id/snapshots/:snapshotId/view * : URL( JWT Token) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../../../lib/types' import { unauthorized, notFound, internalError } from '../../../../../../lib/response' import { verifySignedUrl, extractSignedParams } from '../../../../../../lib/signed-url' import { generateImageSig } from '../../../../../../lib/image-sig' // GET /api/v1/bookmarks/:id/snapshots/:snapshotId/view - URL export const onRequestGet: PagesFunction = async (context) => { try { const bookmarkId = context.params.id as string const snapshotId = context.params.snapshotId as string const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const { signature, expires, userId, action } = extractSignedParams(context.request as unknown as Request) if (!signature || !expires || !userId) { return unauthorized('Missing signature parameters') } // const verification = await verifySignedUrl( signature, expires, userId, snapshotId, context.env.JWT_SECRET, action || undefined ) if (!verification.valid) { return unauthorized(verification.error || 'Invalid signature') } // const snapshot = await db .prepare( `SELECT s.*, b.url as bookmark_url FROM bookmark_snapshots s JOIN bookmarks b ON s.bookmark_id = b.id WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?` ) .bind(snapshotId, bookmarkId, userId) .first() if (!snapshot) { return notFound('Snapshot not found') } // R2 const r2Object = await bucket.get(snapshot.r2_key as string) if (!r2Object) { return notFound('Snapshot file not found') } // HTML let htmlContent = await r2Object.text() const htmlSize = new Blob([htmlContent]).size console.log(`[Snapshot View API] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB`) // V2 ( /api/snapshot-images/ ) const isV2 = htmlContent.includes('/api/snapshot-images/') if (isV2) { const version = (snapshot as Record).version as number || 1 // hash const imgUrlRegex = /\/api\/snapshot-images\/([a-zA-Z0-9._-]+?)(?:\?[^"\s)]*)?(?=["\s)]|$)/g const matches = Array.from(htmlContent.matchAll(imgUrlRegex)) const uniqueHashes = [...new Set(matches.map(m => m[1]).filter(h => h.length <= 128))] // const sigMap = new Map() for (const hash of uniqueHashes) { sigMap.set(hash, await generateImageSig(hash, userId, bookmarkId, context.env.JWT_SECRET)) } let replacedCount = 0 htmlContent = htmlContent.replace(imgUrlRegex, (_match: string, hash: string) => { replacedCount++ const sig = sigMap.get(hash) || '' return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}&sig=${sig}` }) console.log(`[Snapshot View API] V2 format: normalized ${replacedCount} image URLs with signatures`) } return new Response(htmlContent, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600', 'X-Content-Type-Options': 'nosniff', // CSP :, 'Content-Security-Policy': "default-src 'none'; img-src * data: blob:; style-src 'unsafe-inline' *; font-src * data:; frame-src 'none'; script-src 'none'; connect-src 'none';", // X-Frame-Options iframe 'X-Frame-Options': 'DENY', }, }) } catch (error) { console.error('[Snapshot View API] Error:', error) return internalError('Failed to get snapshot') } } ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshots/[snapshotId].ts ================================================ /** * API (V1 - JWT Auth) * : /api/v1/bookmarks/:id/snapshots/:snapshotId * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../../lib/types' import { notFound, internalError } from '../../../../../lib/response' import { requireAuth, AuthContext } from '../../../../../middleware/auth' // GET /api/v1/bookmarks/:id/snapshots/:snapshotId - export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const bookmarkId = context.params.id as string const snapshotId = context.params.snapshotId as string const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const snapshot = await db .prepare( `SELECT s.*, b.url as bookmark_url FROM bookmark_snapshots s JOIN bookmarks b ON s.bookmark_id = b.id WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?` ) .bind(snapshotId, bookmarkId, userId) .first() if (!snapshot) { return notFound('Snapshot not found') } // R2 const r2Object = await bucket.get(snapshot.r2_key as string) if (!r2Object) { return notFound('Snapshot file not found') } // HTML let htmlContent = await r2Object.text() // data URL () const dataUrlCount = (htmlContent.match(/src="data:/g) || []).length const htmlSize = new Blob([htmlContent]).size console.log(`[Snapshot API V1] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`) // CSP meta HTML head () const cspMetaTag = ''; if (htmlContent.includes('')) { htmlContent = htmlContent.replace('', `${cspMetaTag}`); console.log(`[Snapshot API V1] Injected CSP meta tag`); } else if (htmlContent.includes('')) { htmlContent = htmlContent.replace('', `${cspMetaTag}`); console.log(`[Snapshot API V1] Injected CSP meta tag`); } // V2 ( /api/snapshot-images/ ) const isV2 = htmlContent.includes('/api/snapshot-images/') if (isV2) { const version = (snapshot as Record).version as number || 1 // URL: URL, let replacedCount = 0 htmlContent = htmlContent.replace( /\/api\/snapshot-images\/([a-zA-Z0-9._-]+?)(?:\?[^"\s)]*)?(?=["\s)]|$)/g, (_match: string, hash: string) => { replacedCount++ // ,() return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`; } ) console.log(`[Snapshot API V1] V2 format detected, normalized ${replacedCount} image URLs`) } return new Response(htmlContent, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600', 'X-Content-Type-Options': 'nosniff', // CSP () 'Content-Security-Policy': "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; img-src * data: blob:; font-src * data:; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'; frame-src *; connect-src *;", }, }) } catch (error) { console.error('[Snapshot API V1] Get snapshot error:', error) return internalError('Failed to get snapshot') } }, ] // DELETE /api/v1/bookmarks/:id/snapshots/:snapshotId - export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id as string const snapshotId = context.params.snapshotId as string try { const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const snapshot = await db .prepare( `SELECT id, r2_key, is_latest, version FROM bookmark_snapshots WHERE id = ? AND bookmark_id = ? AND user_id = ?` ) .bind(snapshotId, bookmarkId, userId) .first() if (!snapshot) { return notFound('Snapshot not found') } const version = (snapshot as Record).version as number || 1 // R2 HTML await bucket.delete(snapshot.r2_key as string) // V2 () try { // const imagePrefix = `${userId}/${bookmarkId}/v${version}/images/` const imageList = await bucket.list({ prefix: imagePrefix }) if (imageList.objects && imageList.objects.length > 0) { console.log(`[Snapshot API V1] Deleting ${imageList.objects.length} images for version ${version}`) // for (const obj of imageList.objects) { await bucket.delete(obj.key) } } } catch (error) { console.warn('[Snapshot API V1] Failed to delete images:', error) // , } // await db .prepare('DELETE FROM bookmark_snapshots WHERE id = ?') .bind(snapshotId) .run() // (1) await db .prepare( `UPDATE bookmarks SET snapshot_count = MAX(0, snapshot_count - 1) WHERE id = ?` ) .bind(bookmarkId) .run() // , if (snapshot.is_latest) { const nextLatest = await db .prepare( `SELECT id FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC LIMIT 1` ) .bind(bookmarkId, userId) .first() if (nextLatest) { await db .prepare( `UPDATE bookmark_snapshots SET is_latest = 1 WHERE id = ?` ) .bind(nextLatest.id) .run() } else { // , await db .prepare( `UPDATE bookmarks SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0 WHERE id = ?` ) .bind(bookmarkId) .run() } } return new Response(JSON.stringify({ success: true, data: { message: 'Snapshot deleted successfully' } }), { headers: { 'Content-Type': 'application/json' } }) } catch (error) { console.error('[Snapshot API V1] Delete snapshot error:', error) return internalError('Failed to delete snapshot') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshots/cleanup.ts ================================================ /** * API * : /api/v1/bookmarks/:id/snapshots/cleanup * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../../lib/response' import { requireAuth, AuthContext } from '../../../../../middleware/auth' interface CleanupRequest { keep_count?: number older_than_days?: number verify_and_fix?: boolean } interface RouteParams { id: string } interface SnapshotRow { id: string r2_key: string file_size: number } async function deleteSnapshotRows( db: D1Database, ids: string[], userId: string, ): Promise { const placeholders = ids.map(() => '?').join(',') await db .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`) .bind(...ids, userId) .run() } async function updateBookmarkSnapshotCount( db: D1Database, bookmarkId: string, userId: string, ): Promise { const remaining = await db .prepare( `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`, ) .bind(bookmarkId, userId) .first() const remainingCount = (remaining?.count as number) || 0 if (remainingCount === 0) { await db .prepare( `UPDATE bookmarks SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0 WHERE id = ? AND user_id = ?`, ) .bind(bookmarkId, userId) .run() } else { await db .prepare(`UPDATE bookmarks SET snapshot_count = ? WHERE id = ? AND user_id = ?`) .bind(remainingCount, bookmarkId, userId) .run() } } async function handleVerifyAndFix( db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string, ) { const { results: allSnapshots } = await db .prepare( `SELECT id, r2_key, file_size FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`, ) .bind(bookmarkId, userId) .all() const orphaned: SnapshotRow[] = [] for (const snapshot of allSnapshots || []) { try { const r2Object = await bucket.head(snapshot.r2_key) if (!r2Object) orphaned.push(snapshot) } catch { orphaned.push(snapshot) } } if (orphaned.length === 0) { return success({ deleted_count: 0, freed_space: 0, message: 'All snapshots are valid, no orphaned records found', }) } await deleteSnapshotRows(db, orphaned.map((s) => s.id), userId) await updateBookmarkSnapshotCount(db, bookmarkId, userId) return success({ deleted_count: orphaned.length, freed_space: 0, message: `Fixed ${orphaned.length} orphaned snapshot records`, }) } // POST /api/v1/bookmarks/:id/snapshots/cleanup export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const body = await context.request.json() as CleanupRequest const { keep_count, older_than_days, verify_and_fix } = body const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) return notFound('Bookmark not found') if (verify_and_fix) { return handleVerifyAndFix(db, bucket, bookmarkId, userId) } let toDelete: SnapshotRow[] = [] if (keep_count !== undefined && keep_count >= 0) { const result = await db .prepare( `SELECT id, r2_key, file_size FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC LIMIT -1 OFFSET ?`, ) .bind(bookmarkId, userId, keep_count) .all() toDelete = result.results || [] } else if (older_than_days !== undefined && older_than_days > 0) { const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - older_than_days) const result = await db .prepare( `SELECT id, r2_key, file_size FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? AND created_at < ? ORDER BY version ASC`, ) .bind(bookmarkId, userId, cutoffDate.toISOString()) .all() toDelete = result.results || [] } else { return badRequest('Must specify keep_count or older_than_days') } if (toDelete.length === 0) { return success({ deleted_count: 0, freed_space: 0, message: 'No snapshots to delete' }) } const freedSpace = toDelete.reduce((sum, s) => sum + (s.file_size as number || 0), 0) for (const snapshot of toDelete) { await bucket.delete(snapshot.r2_key as string) } await deleteSnapshotRows(db, toDelete.map((s) => s.id), userId) await updateBookmarkSnapshotCount(db, bookmarkId, userId) return success({ deleted_count: toDelete.length, freed_space: freedSpace, message: `Deleted ${toDelete.length} snapshots`, }) } catch (error) { console.error('Cleanup snapshots error:', error) return internalError('Failed to cleanup snapshots') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id]/snapshots.ts ================================================ /** * API * : /api/v1/bookmarks/:id/snapshots * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' import { generateSignedUrl } from '../../../../lib/signed-url' import { generateNanoId } from '../../../../lib/crypto' import { checkR2Quota } from '../../../../lib/storage-quota' import { cleanupOldSnapshots } from './snapshot-cleanup' // Web Crypto API SHA-256 async function sha256(content: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(content) const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') } interface CreateSnapshotRequest { html_content: string title: string url: string force?: boolean } // const MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB // GET /api/v1/bookmarks/:id/snapshots - export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const db = context.env.DB // const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // const snapshots = await db .prepare( `SELECT id, version, file_size, content_hash, snapshot_title, is_latest, created_at FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ? ORDER BY version DESC` ) .bind(bookmarkId, userId) .all() // URL const snapshotsWithUrls = await Promise.all( (snapshots.results || []).map(async (snapshot: Record) => { // 24 URL const { signature, expires } = await generateSignedUrl( { userId, resourceId: snapshot.id, expiresIn: 24 * 3600, // 24 action: 'view', }, context.env.JWT_SECRET ) // URL const baseUrl = new URL(context.request.url).origin const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshot.id}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view` return { ...snapshot, view_url: viewUrl, } }) ) return success({ snapshots: snapshotsWithUrls, total: snapshotsWithUrls.length, }) } catch (error) { console.error('Get snapshots error:', error) return internalError('Failed to get snapshots') } }, ] // POST /api/v1/bookmarks/:id/snapshots - export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const bookmarkId = context.params.id try { const body = await context.request.json() as CreateSnapshotRequest const { html_content, title, url, force = false } = body if (!html_content || !title || !url) { return badRequest('Missing required fields') } // const originalSize = new Blob([html_content]).size if (originalSize > MAX_SNAPSHOT_SIZE) { return badRequest( `Snapshot too large (${(originalSize / 1024 / 1024).toFixed(2)}MB). Maximum size is ${MAX_SNAPSHOT_SIZE / 1024 / 1024}MB.` ) } const db = context.env.DB const bucket = context.env.SNAPSHOTS_BUCKET if (!bucket) { return internalError('Storage not configured') } // const bookmark = await db .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } // const contentHash = await sha256(html_content) // () if (!force) { const latestSnapshot = await db .prepare( `SELECT content_hash FROM bookmark_snapshots WHERE bookmark_id = ? AND is_latest = 1` ) .bind(bookmarkId) .first() if (latestSnapshot && latestSnapshot.content_hash === contentHash) { return success({ message: 'Content unchanged, no new snapshot created', is_duplicate: true, }) } } // const versionResult = await db .prepare( `SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM bookmark_snapshots WHERE bookmark_id = ?` ) .bind(bookmarkId) .first() const version = versionResult?.next_version as number || 1 // R2 const timestamp = Date.now() const r2Key = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html` // HTML UTF-8 const encoder = new TextEncoder() const htmlBytes = encoder.encode(html_content) // ( bookmark_snapshots.file_size ) const quota = await checkR2Quota(db, context.env, htmlBytes.length) if (!quota.allowed) { const usedGB = quota.usedBytes / (1024 * 1024 * 1024) const limitGB = quota.limitBytes / (1024 * 1024 * 1024) return badRequest({ code: 'R2_STORAGE_LIMIT_EXCEEDED', message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB. Please delete some snapshots or images and try again.`, }) } // UTF-8 R2 await bucket.put(r2Key, htmlBytes, { httpMetadata: { contentType: 'text/html; charset=utf-8', }, customMetadata: { userId, bookmarkId, version: version.toString(), title, fileSize: htmlBytes.length.toString(), }, }) const snapshotId = generateNanoId() const now = new Date().toISOString() // ( INSERT ,) const batch = [ // , db.prepare( `INSERT INTO bookmark_snapshots (id, bookmark_id, user_id, version, is_latest, content_hash, r2_key, r2_bucket, file_size, mime_type, snapshot_url, snapshot_title, snapshot_status, created_at, updated_at) VALUES (?, ?, ?, (SELECT COALESCE(MAX(version), 0) + 1 FROM bookmark_snapshots WHERE bookmark_id = ?), 1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)` ).bind( snapshotId, bookmarkId, userId, bookmarkId, contentHash, r2Key, htmlBytes.length, url, title, now, now ), // is_latest db.prepare( `UPDATE bookmark_snapshots SET is_latest = 0 WHERE bookmark_id = ? AND id != ?` ).bind(bookmarkId, snapshotId), // () db.prepare( `UPDATE bookmarks SET has_snapshot = 1, latest_snapshot_at = ?, snapshot_count = snapshot_count + 1 WHERE id = ?` ).bind(now, bookmarkId), ] await db.batch(batch) // await cleanupOldSnapshots(db, bucket, bookmarkId, userId) // URL(24 ) const { signature, expires } = await generateSignedUrl( { userId, resourceId: snapshotId, expiresIn: 24 * 3600, action: 'view', }, context.env.JWT_SECRET ) // URL const baseUrl = new URL(context.request.url).origin const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshotId}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view` return success({ snapshot: { id: snapshotId, version, file_size: htmlBytes.length, content_hash: contentHash, snapshot_title: title, is_latest: true, created_at: now, view_url: viewUrl, }, message: 'Snapshot created successfully', }) } catch (error) { console.error('Create snapshot error:', error) return internalError('Failed to create snapshot') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/[id].ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, Bookmark, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types' import { success, badRequest, notFound, noContent, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { isValidUrl, sanitizeString } from '../../../lib/validation' import { normalizeBookmark } from '../../../lib/bookmark-utils' import { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags' import { invalidatePublicShareCache } from '../../shared/cache' interface UpdateBookmarkRequest { title?: string url?: string description?: string cover_image?: string favicon?: string tag_ids?: string[] // : ID tags?: string[] // :() is_pinned?: boolean is_public?: boolean } // PATCH /api/v1/bookmarks/:id - export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const bookmarkId = context.params.id const body = await context.request.json() as UpdateBookmarkRequest // const bookmarkRow = await context.env.DB.prepare( 'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!bookmarkRow) { return notFound('Bookmark not found') } // if (body.url && !isValidUrl(body.url)) { return badRequest('Invalid URL format') } // const updates: string[] = [] const values: SQLParam[] = [] if (body.title !== undefined) { updates.push('title = ?') values.push(sanitizeString(body.title, 500)) } if (body.url !== undefined) { updates.push('url = ?') values.push(sanitizeString(body.url, 2000)) } if (body.description !== undefined) { updates.push('description = ?') values.push(body.description ? sanitizeString(body.description, 1000) : null) } if (body.cover_image !== undefined) { updates.push('cover_image = ?') values.push(body.cover_image ? sanitizeString(body.cover_image, 2000) : null) } if (body.favicon !== undefined) { updates.push('favicon = ?') values.push(body.favicon ? sanitizeString(body.favicon, 2000) : null) } if (body.is_pinned !== undefined) { updates.push('is_pinned = ?') values.push(body.is_pinned ? 1 : 0) } if (body.is_public !== undefined) { updates.push('is_public = ?') values.push(body.is_public ? 1 : 0) } const now = new Date().toISOString() if (updates.length > 0) { updates.push('updated_at = ?') values.push(now) values.push(bookmarkId, userId) await context.env.DB.prepare( `UPDATE bookmarks SET ${updates.join(', ')} WHERE id = ? AND user_id = ?` ) .bind(...values) .run() } // if (body.tags !== undefined) { await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now) } else if (body.tag_ids !== undefined) { const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids) await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now) } // const updatedBookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?') .bind(bookmarkId, userId) .first() const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL` ) .bind(bookmarkId, userId) .all<{ id: string; name: string; color: string | null }>() if (!updatedBookmarkRow) { return internalError('Failed to load bookmark after update') } await invalidatePublicShareCache(context.env, userId) return success({ bookmark: { ...normalizeBookmark(updatedBookmarkRow), tags: tags || [], }, }) } catch (error) { console.error('Update bookmark error:', error) return internalError('Failed to update bookmark') } }, ] // DELETE /api/v1/bookmarks/:id - export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const bookmarkId = context.params.id // const bookmark = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Bookmark not found') } const now = new Date().toISOString() await context.env.DB.prepare( 'UPDATE bookmarks SET deleted_at = ?, updated_at = ?, click_count = 0, last_clicked_at = NULL WHERE id = ? AND user_id = ?' ).bind(now, now, bookmarkId, userId).run() await invalidatePublicShareCache(context.env, userId) return noContent() } catch (error) { console.error('Delete bookmark error:', error) return internalError('Failed to delete bookmark') } }, ] // PUT /api/v1/bookmarks/:id - export const onRequestPut: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const bookmarkId = context.params.id // 、 const bookmark = await context.env.DB.prepare( 'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) .bind(bookmarkId, userId) .first() if (!bookmark) { return notFound('Deleted bookmark not found') } // const now = new Date().toISOString() await context.env.DB.prepare( 'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?' ) .bind(now, bookmarkId, userId) .run() // const restoredBookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?') .bind(bookmarkId, userId) .first() const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL` ) .bind(bookmarkId, userId) .all<{ id: string; name: string; color: string | null }>() if (!restoredBookmarkRow) { return internalError('Failed to load bookmark after restore') } await invalidatePublicShareCache(context.env, userId) return success({ bookmark: { ...normalizeBookmark(restoredBookmarkRow), tags: tags || [], }, }) } catch (error) { console.error('Restore bookmark error:', error) return internalError('Failed to restore bookmark') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/bulk.ts ================================================ import type { PagesFunction, D1PreparedStatement } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { requireAuth, type AuthContext } from '../../../middleware/auth' import { invalidatePublicShareCache } from '../../shared/cache' import { CacheService } from '../../../lib/cache' import { createBookmarkCacheManager } from '../../../lib/cache/bookmark-cache' type BatchActionType = 'delete' | 'update_tags' | 'pin' | 'unpin' | 'archive' | 'unarchive' interface BatchActionRequest { action: BatchActionType bookmark_ids: string[] add_tag_ids?: string[] remove_tag_ids?: string[] } interface BatchActionResponse { success: boolean affected_count: number errors?: Array<{ bookmark_id: string; message: string }> } export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id let body: BatchActionRequest | null = null try { body = (await context.request.json()) as BatchActionRequest const { action, bookmark_ids, add_tag_ids, remove_tag_ids } = body if (!action || !bookmark_ids || !Array.isArray(bookmark_ids) || bookmark_ids.length === 0) { return new Response(JSON.stringify({ code: 'INVALID_REQUEST', message: 'action and bookmark_ids are required' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } if (bookmark_ids.length > 100) { return new Response(JSON.stringify({ code: 'TOO_MANY_ITEMS', message: 'Cannot process more than 100 bookmarks at once' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } const db = context.env.DB const placeholders = bookmark_ids.map(() => '?').join(',') let affectedCount = 0 const errors: Array<{ bookmark_id: string; message: string }> = [] switch (action) { case 'delete': { const result = await db.prepare( `UPDATE bookmarks SET deleted_at = datetime('now'), click_count = 0, last_clicked_at = NULL WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL` ).bind(...bookmark_ids, userId).run() affectedCount = result.meta.changes || 0 await db.prepare(`INSERT INTO audit_logs (user_id, event_type, payload, created_at) VALUES (?, 'batch_delete_bookmarks', ?, datetime('now'))`).bind(userId, JSON.stringify({ bookmark_ids, count: affectedCount })).run() break } case 'pin': { const result = await db.prepare(`UPDATE bookmarks SET is_pinned = 1, updated_at = datetime('now') WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...bookmark_ids, userId).run() affectedCount = result.meta.changes || 0 break } case 'unpin': { const result = await db.prepare(`UPDATE bookmarks SET is_pinned = 0, updated_at = datetime('now') WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...bookmark_ids, userId).run() affectedCount = result.meta.changes || 0 break } case 'update_tags': { if (add_tag_ids && add_tag_ids.length > 50) { return new Response(JSON.stringify({ code: 'INVALID_REQUEST', message: 'Cannot add more than 50 tags at once' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } if (remove_tag_ids && remove_tag_ids.length > 50) { return new Response(JSON.stringify({ code: 'INVALID_REQUEST', message: 'Cannot remove more than 50 tags at once' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } const verifyResult = await db.prepare(`SELECT id FROM bookmarks WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...bookmark_ids, userId).all<{ id: string }>() const validBookmarkIds = verifyResult.results.map((row: { id: string }) => row.id) if (validBookmarkIds.length === 0) { return new Response(JSON.stringify({ code: 'NO_VALID_BOOKMARKS', message: 'No valid bookmarks found' }), { status: 404, headers: { 'Content-Type': 'application/json' } }) } // Collect all tag mutation statements for atomic batch execution const tagStmts: D1PreparedStatement[] = [] if (remove_tag_ids && remove_tag_ids.length > 0) { const tagPlaceholders = remove_tag_ids.map(() => '?').join(',') const bookmarkPlaceholders = validBookmarkIds.map(() => '?').join(',') tagStmts.push( db.prepare(`DELETE FROM bookmark_tags WHERE bookmark_id IN (${bookmarkPlaceholders}) AND tag_id IN (${tagPlaceholders}) AND user_id = ?`).bind(...validBookmarkIds, ...remove_tag_ids, userId) ) } let validTagIds: string[] = [] if (add_tag_ids && add_tag_ids.length > 0) { const tagPlaceholders = add_tag_ids.map(() => '?').join(',') const tagsResult = await db.prepare(`SELECT id FROM tags WHERE id IN (${tagPlaceholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...add_tag_ids, userId).all<{ id: string }>() validTagIds = tagsResult.results.map((row: { id: string }) => row.id) if (validTagIds.length > 0) { for (const bookmarkId of validBookmarkIds) { for (const tagId of validTagIds) { tagStmts.push( db.prepare(`INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id, user_id, created_at) VALUES (?, ?, ?, datetime('now'))`).bind(bookmarkId, tagId, userId) ) } } } } // Add usage count updates, bookmark timestamps, and audit log const bookmarkPlaceholders = validBookmarkIds.map(() => '?').join(',') if (validTagIds.length > 0) { for (const tagId of validTagIds) { tagStmts.push( db.prepare(`UPDATE tags SET usage_count = (SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = ? AND user_id = ?) WHERE id = ? AND user_id = ?`).bind(tagId, userId, tagId, userId) ) } } if (remove_tag_ids && remove_tag_ids.length > 0) { for (const tagId of remove_tag_ids) { tagStmts.push( db.prepare(`UPDATE tags SET usage_count = (SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = ? AND user_id = ?) WHERE id = ? AND user_id = ?`).bind(tagId, userId, tagId, userId) ) } } tagStmts.push( db.prepare(`UPDATE bookmarks SET updated_at = datetime('now') WHERE id IN (${bookmarkPlaceholders}) AND user_id = ?`).bind(...validBookmarkIds, userId), db.prepare(`INSERT INTO audit_logs (user_id, event_type, payload, created_at) VALUES (?, 'batch_update_tags', ?, datetime('now'))`).bind(userId, JSON.stringify({ bookmark_ids: validBookmarkIds, add_tag_ids, remove_tag_ids })), ) affectedCount = validBookmarkIds.length try { await db.batch(tagStmts) } catch (e) { console.error('Batch tag update failed:', e) errors.push({ bookmark_id: 'batch', message: 'Failed to update tags in batch' }) } break } default: return new Response(JSON.stringify({ code: 'INVALID_ACTION', message: `Invalid action: ${action}` }), { status: 400, headers: { 'Content-Type': 'application/json' } }) } const response: BatchActionResponse = { success: true, affected_count: affectedCount } if (errors.length > 0) response.errors = errors const cache = new CacheService(context.env) const bookmarkCache = createBookmarkCacheManager(cache) await bookmarkCache.handleBatchOperation(userId) await invalidatePublicShareCache(context.env, userId) return new Response(JSON.stringify(response), { status: 200, headers: { 'Content-Type': 'application/json' } }) } catch (error) { console.error('Batch operation error:', error) return new Response(JSON.stringify({ code: 'INTERNAL_ERROR', message: 'Failed to perform batch operation' }), { status: 500, headers: { 'Content-Type': 'application/json' } }) } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/index.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types' import { success, badRequest, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { fetchFullBookmarks } from '../../../lib/data-fetchers' export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const { env, request, data } = context const db = env.DB const userId = data.user_id if (!userId) { return badRequest('User not authenticated') } try { const url = new URL(request.url) const keyword = url.searchParams.get('keyword') const tagIds = url.searchParams.get('tagIds')?.split(',').filter(Boolean) const groupId = url.searchParams.get('groupId') const sortBy = url.searchParams.get('sortBy') || 'created_at' const sortOrder = url.searchParams.get('sortOrder') || 'DESC' const limit = parseInt(url.searchParams.get('limit') || '50', 10) || 50 const offset = parseInt(url.searchParams.get('offset') || '0', 10) || 0 let query = ` SELECT DISTINCT b.* FROM bookmarks b LEFT JOIN bookmark_tags bt ON b.id = bt.bookmark_id WHERE b.user_id = ? ` const params: SQLParam[] = [userId] const conditions: string[] = [] const conditionParams: SQLParam[] = [] if (keyword) { conditions.push('(b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)') const searchPattern = `%${keyword}%` conditionParams.push(searchPattern, searchPattern, searchPattern) } if (tagIds && tagIds.length > 0) { conditions.push(`bt.tag_id IN (${tagIds.map(() => '?').join(',')})`) conditionParams.push(...tagIds) } if (groupId) { if (groupId === 'none') { conditions.push('b.group_id IS NULL') } else { conditions.push('b.group_id = ?') conditionParams.push(groupId) } } if (conditions.length > 0) { query += ' AND ' + conditions.join(' AND ') } const countQuery = `SELECT COUNT(DISTINCT b.id) as total FROM bookmarks b LEFT JOIN bookmark_tags bt ON b.id = bt.bookmark_id WHERE b.user_id = ? ${conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : ''}` const totalResult = await db.prepare(countQuery).bind(userId, ...conditionParams).first<{ total: number }>() const total = totalResult?.total || 0 const validSortFields = ['created_at', 'updated_at', 'title'] const sortField = validSortFields.includes(sortBy) ? sortBy : 'created_at' const direction = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC' query += ` ORDER BY b.${sortField} ${direction} LIMIT ? OFFSET ?` params.push(...conditionParams, limit, offset) const { results: rows } = await db.prepare(query).bind(...params).all() const bookmarks = await fetchFullBookmarks(db, rows, userId) return success({ bookmarks, pagination: { total, limit, offset, }, }) } catch (error: unknown) { console.error('Fetch bookmarks error:', error) return internalError('Failed to fetch bookmarks') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/statistics-helpers.ts ================================================ export interface BookmarkStatistics { summary: { total_bookmarks: number total_tags: number total_clicks: number public_bookmarks: number } top_bookmarks: Array<{ id: string title: string url: string click_count: number last_clicked_at: string | null }> top_tags: Array<{ id: string name: string color: string | null click_count: number bookmark_count: number }> top_domains: Array<{ domain: string count: number }> bookmark_clicks: Array<{ id: string title: string url: string click_count: number }> recent_clicks: Array<{ id: string title: string url: string last_clicked_at: string }> trends: { bookmarks: Array<{ date: string; count: number }> clicks: Array<{ date: string; count: number }> } } /** * SQL * @param granularity : day, week, month, year * @param field */ export function getDateGroupSql(granularity: string, field: string) { let dateGroupBy = '' let dateSelect = '' switch (granularity) { case 'year': dateGroupBy = `strftime('%Y', ${field})` dateSelect = `strftime('%Y', ${field}) as date` break case 'month': dateGroupBy = `strftime('%Y-%m', ${field})` dateSelect = `strftime('%Y-%m', ${field}) as date` break case 'week': dateGroupBy = `strftime('%Y-W%W', ${field})` dateSelect = `strftime('%Y-W%W', ${field}) as date` break case 'day': default: dateGroupBy = `DATE(${field})` dateSelect = `DATE(${field}) as date` break } return { dateGroupBy, dateSelect } } ================================================ FILE: tmarks/functions/api/v1/bookmarks/statistics.ts ================================================ /** * API * : /api/v1/bookmarks/statistics * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { BookmarkStatistics, getDateGroupSql } from './statistics-helpers' // GET /api/v1/bookmarks/statistics - export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) // const granularity = url.searchParams.get('granularity') || 'day' // day, week, month, year const startDate = url.searchParams.get('start_date') // YYYY-MM-DD const endDate = url.searchParams.get('end_date') // YYYY-MM-DD try { const db = context.env.DB const { dateGroupBy, dateSelect } = getDateGroupSql(granularity, 'created_at') const { dateGroupBy: clickDateGroupBy, dateSelect: clickDateSelect } = getDateGroupSql(granularity, 'clicked_at') const [ summary, tagCount, topBookmarks, topTags, topDomains, recentClicks, bookmarkTrends, clickTrends, bookmarkClickStats, ] = await Promise.all([ db.prepare( `SELECT COUNT(*) as total_bookmarks, SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) as active_bookmarks, SUM(CASE WHEN is_public = 1 AND deleted_at IS NULL THEN 1 ELSE 0 END) as public_bookmarks, SUM(click_count) as total_clicks FROM bookmarks WHERE user_id = ? AND deleted_at IS NULL` ) .bind(userId) .first(), // 2. db.prepare( `SELECT COUNT(*) as total_tags FROM tags WHERE user_id = ? AND deleted_at IS NULL` ) .bind(userId) .first(), // 3. Top 10 db.prepare( `SELECT id, title, url, click_count, last_clicked_at FROM bookmarks WHERE user_id = ? AND deleted_at IS NULL AND click_count > 0 ORDER BY click_count DESC, last_clicked_at DESC LIMIT 10` ) .bind(userId) .all(), // 4. Top 10() db.prepare( `SELECT t.id, t.name, t.color, t.click_count, COUNT(DISTINCT bt.bookmark_id) as bookmark_count FROM tags t LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE t.user_id = ? AND t.deleted_at IS NULL GROUP BY t.id, t.name, t.color, t.click_count ORDER BY t.click_count DESC, bookmark_count DESC LIMIT 10` ) .bind(userId) .all(), // 5. Top 10 db.prepare( `SELECT CASE WHEN url LIKE 'http://%' THEN substr(url, 8, instr(substr(url, 8), '/') - 1) WHEN url LIKE 'https://%' THEN substr(url, 9, instr(substr(url, 9), '/') - 1) ELSE url END as domain, COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NULL GROUP BY domain ORDER BY count DESC LIMIT 10` ) .bind(userId) .all(), db.prepare( `SELECT id, title, url, last_clicked_at FROM bookmarks WHERE user_id = ? AND deleted_at IS NULL AND last_clicked_at IS NOT NULL ORDER BY last_clicked_at DESC LIMIT 10` ) .bind(userId) .all(), // 7. db.prepare( `SELECT ${dateSelect}, COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NULL ${startDate ? `AND DATE(created_at) >= ?` : ''} ${endDate ? `AND DATE(created_at) <= ?` : ''} GROUP BY ${dateGroupBy} ORDER BY date ASC` ) .bind(userId, ...[startDate, endDate].filter(Boolean)) .all(), db.prepare( `SELECT ${clickDateSelect}, COUNT(*) as count FROM bookmark_click_events WHERE user_id = ? ${startDate ? `AND DATE(clicked_at) >= ?` : ''} ${endDate ? `AND DATE(clicked_at) <= ?` : ''} GROUP BY ${clickDateGroupBy} ORDER BY date ASC` ) .bind(userId, ...[startDate, endDate].filter(Boolean)) .all(), // 9. db.prepare( `SELECT b.id, b.title, b.url, COUNT(e.id) as click_count FROM bookmark_click_events e JOIN bookmarks b ON e.bookmark_id = b.id WHERE e.user_id = ? AND b.deleted_at IS NULL ${startDate ? `AND DATE(e.clicked_at) >= ?` : ''} ${endDate ? `AND DATE(e.clicked_at) <= ?` : ''} GROUP BY b.id, b.title, b.url ORDER BY click_count DESC` ) .bind(userId, ...[startDate, endDate].filter(Boolean)) .all() ]) const statistics: BookmarkStatistics = { summary: { total_bookmarks: (summary?.total_bookmarks as number) || 0, total_tags: (tagCount?.total_tags as number) || 0, total_clicks: (summary?.total_clicks as number) || 0, public_bookmarks: (summary?.public_bookmarks as number) || 0, }, top_bookmarks: (topBookmarks.results || []) as Array<{ id: string title: string url: string click_count: number last_clicked_at: string | null }>, top_tags: (topTags.results || []) as Array<{ id: string name: string color: string | null click_count: number bookmark_count: number }>, top_domains: (topDomains.results || []) as Array<{ domain: string count: number }>, recent_clicks: (recentClicks.results || []) as Array<{ id: string title: string url: string last_clicked_at: string }>, bookmark_clicks: (bookmarkClickStats.results || []) as Array<{ id: string title: string url: string click_count: number }>, trends: { bookmarks: (bookmarkTrends.results || []) as Array<{ date: string; count: number }>, clicks: (clickTrends.results || []) as Array<{ date: string; count: number }>, }, } return success(statistics) } catch (error) { console.error('Get bookmark statistics error:', error) return internalError('Failed to get statistics') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/trash/empty.ts ================================================ /** * : /api/v1/bookmarks/trash/empty * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../../lib/types' import { success, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const { results: trashBookmarks } = await context.env.DB.prepare( 'SELECT id FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL' ) .bind(userId) .all<{ id: string }>() if (trashBookmarks.length === 0) { return success({ message: 'Trash is already empty', count: 0 }) } const bookmarkIds = trashBookmarks.map(b => b.id) // for (const id of bookmarkIds) { await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?') .bind(id) .run() } // for (const id of bookmarkIds) { await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?') .bind(id) .run() } // await context.env.DB.prepare( 'DELETE FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL' ) .bind(userId) .run() return success({ message: 'Trash emptied successfully', count: bookmarkIds.length, }) } catch (error) { console.error('Empty trash error:', error) return internalError('Failed to empty trash') } }, ] ================================================ FILE: tmarks/functions/api/v1/bookmarks/trash.ts ================================================ /** * : /api/v1/bookmarks/trash * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, BookmarkRow } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { normalizeBookmark } from '../../../lib/bookmark-utils' interface TrashQueryParams { page_size?: string page_cursor?: string sort?: string } export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const params: TrashQueryParams = { page_size: url.searchParams.get('page_size') || undefined, page_cursor: url.searchParams.get('page_cursor') || undefined, sort: url.searchParams.get('sort') || undefined, } try { const pageSize = Math.min(Math.max(parseInt(params.page_size || '20', 10) || 20, 1), 100) const sort = params.sort === 'deleted_at_asc' ? 'ASC' : 'DESC' let query = ` SELECT * FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL ` const queryParams: (string | number)[] = [userId] // if (params.page_cursor) { query += ` AND deleted_at < ?` queryParams.push(params.page_cursor) } query += ` ORDER BY deleted_at ${sort} LIMIT ?` queryParams.push(pageSize + 1) const { results: bookmarks } = await context.env.DB.prepare(query) .bind(...queryParams) .all() const hasMore = bookmarks.length > pageSize const items = hasMore ? bookmarks.slice(0, pageSize) : bookmarks const bookmarksWithTags = await Promise.all( items.map(async (bookmark) => { const { results: tags } = await context.env.DB.prepare( `SELECT t.id, t.name, t.color FROM tags t INNER JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL` ) .bind(bookmark.id) .all<{ id: string; name: string; color: string | null }>() return { ...normalizeBookmark(bookmark), tags: tags || [], } }) ) // const countResult = await context.env.DB.prepare( 'SELECT COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL' ) .bind(userId) .first<{ count: number }>() return success({ bookmarks: bookmarksWithTags, meta: { total: countResult?.count || 0, page_size: pageSize, has_more: hasMore, next_cursor: hasMore && items.length > 0 ? items[items.length - 1].deleted_at : null, }, }) } catch (error) { console.error('Get trash bookmarks error:', error) return internalError('Failed to get trash bookmarks') } }, ] ================================================ FILE: tmarks/functions/api/v1/change-password.ts ================================================ /** * Change Password API * Path: /api/v1/change-password * Auth: JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../lib/types' import { success, badRequest, unauthorized, internalError } from '../../lib/response' import { requireAuth, AuthContext } from '../../middleware/auth' import { hashPassword, verifyPassword } from '../../lib/crypto' interface ChangePasswordRequest { current_password: string new_password: string } // POST /api/v1/change-password - Change password export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as ChangePasswordRequest // Validate request parameters if (!body.current_password || !body.new_password) { return badRequest('Current password and new password are required') } // Validate new password length if (body.new_password.length < 6) { return badRequest('New password must be at least 6 characters') } // Get user's current password hash const user = await context.env.DB.prepare( 'SELECT password_hash FROM users WHERE id = ?' ) .bind(userId) .first<{ password_hash: string }>() if (!user) { return unauthorized('User not found') } // Verify current password const isCurrentPasswordValid = await verifyPassword( body.current_password, user.password_hash ) if (!isCurrentPasswordValid) { return unauthorized('Current password is incorrect') } // Generate new password hash const newPasswordHash = await hashPassword(body.new_password) // Update password const now = new Date().toISOString() await context.env.DB.prepare( 'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?' ) .bind(newPasswordHash, now, userId) .run() return success({ message: 'Password changed successfully', }) } catch (error) { console.error('Change password error:', error) return internalError('Failed to change password') } }, ] ================================================ FILE: tmarks/functions/api/v1/export.ts ================================================ /** * Export API endpoint * GET /api/v1/export -> download JSON export * POST /api/v1/export -> preview stats (counts + estimated size) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../lib/types' import { requireAuth, type AuthContext } from '../../middleware/auth' import type { ExportOptions } from '../../../shared/import-export-types' import { createJsonExporter } from '../../lib/import-export/exporters/json-exporter' import { collectExportData } from '../../lib/import-export/collect-export-data' import { parseExportScope } from '../../lib/import-export/export-scope' import { getExportFilename } from '../../lib/import-export/export-scope' import { estimateExportSize, getExportStats } from '../../lib/import-export/export-stats' interface ExportPreviewRequest { format?: string scope?: string include_deleted?: boolean } function parseCommonOptions(url: URL): { scope: ReturnType includeDeleted: boolean options: ExportOptions } { const requestedFormat = url.searchParams.get('format') ?? 'json' if (requestedFormat !== 'json') { throw new Error(`Unsupported export format: ${requestedFormat}`) } const scope = parseExportScope(url.searchParams.get('scope')) const includeDeleted = url.searchParams.get('include_deleted') === 'true' const includeMetadata = url.searchParams.get('include_metadata') !== 'false' const includeTags = url.searchParams.get('include_tags') !== 'false' const prettyPrint = url.searchParams.get('pretty_print') !== 'false' const options: ExportOptions = { include_tags: includeTags, include_metadata: includeMetadata, format_options: { pretty_print: prettyPrint, include_click_stats: url.searchParams.get('include_stats') === 'true', include_user_info: url.searchParams.get('include_user') === 'true', }, } return { scope, includeDeleted, options } } export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const url = new URL(context.request.url) let scope: ReturnType let includeDeleted: boolean let options: ExportOptions try { ;({ scope, includeDeleted, options } = parseCommonOptions(url)) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.startsWith('Unsupported export format:')) { return new Response( JSON.stringify({ error: 'Unsupported export format', message }), { status: 400, headers: { 'Content-Type': 'application/json' } } ) } throw error } const exportData = await collectExportData(context.env.DB, userId, scope, includeDeleted) const jsonExporter = createJsonExporter() const result = await jsonExporter.export(exportData, options) const filename = getExportFilename(exportData.exported_at, scope) return new Response(result.content, { status: 200, headers: { 'Content-Type': result.mimeType, 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': result.size.toString(), 'Cache-Control': 'no-cache, no-store, must-revalidate', }, }) } catch (error) { console.error('Export error:', error) return new Response( JSON.stringify({ error: 'Export failed', message: error instanceof Error ? error.message : 'Unknown error', }), { status: 500, headers: { 'Content-Type': 'application/json' } } ) } }, ] export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const body = (await context.request.json()) as ExportPreviewRequest const requestedFormat = body.format ?? 'json' if (requestedFormat !== 'json') { return new Response( JSON.stringify({ error: 'Unsupported export format', message: `Unsupported export format: ${requestedFormat}`, }), { status: 400, headers: { 'Content-Type': 'application/json' } } ) } const scope = parseExportScope(body.scope) const includeDeleted = Boolean(body.include_deleted) const stats = await getExportStats(context.env.DB, userId, scope, includeDeleted) const estimatedSize = estimateExportSize(stats) const estimatedFilename = getExportFilename(new Date().toISOString(), scope) return new Response( JSON.stringify({ stats, estimated_size: estimatedSize, format: 'json', estimated_filename: estimatedFilename, }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) } catch (error) { console.error('Export preview error:', error) return new Response(JSON.stringify({ error: 'Failed to get export preview' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }) } }, ] ================================================ FILE: tmarks/functions/api/v1/health.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' export const onRequestGet: PagesFunction = async () => { return Response.json({ status: 'ok', timestamp: new Date().toISOString(), version: '0.1.0', }) } ================================================ FILE: tmarks/functions/api/v1/preferences-helpers.ts ================================================ export interface UserPreferences { user_id: string theme: 'light' | 'dark' | 'system' page_size: number view_mode: 'list' | 'card' | 'minimal' | 'title' density: 'compact' | 'normal' | 'comfortable' tag_layout?: 'grid' | 'masonry' sort_by?: 'created' | 'updated' | 'pinned' | 'popular' search_auto_clear_seconds?: number tag_selection_auto_clear_seconds?: number enable_search_auto_clear?: number enable_tag_selection_auto_clear?: number default_bookmark_icon?: string snapshot_retention_count?: number snapshot_auto_create?: number snapshot_auto_dedupe?: number snapshot_auto_cleanup_days?: number updated_at: string } export interface UpdatePreferencesRequest { theme?: 'light' | 'dark' | 'system' page_size?: number view_mode?: 'list' | 'card' | 'minimal' | 'title' density?: 'compact' | 'normal' | 'comfortable' tag_layout?: 'grid' | 'masonry' sort_by?: 'created' | 'updated' | 'pinned' | 'popular' search_auto_clear_seconds?: number tag_selection_auto_clear_seconds?: number enable_search_auto_clear?: boolean enable_tag_selection_auto_clear?: boolean default_bookmark_icon?: string snapshot_retention_count?: number snapshot_auto_create?: boolean snapshot_auto_dedupe?: boolean snapshot_auto_cleanup_days?: number } export async function hasTagLayoutColumn(db: D1Database): Promise { try { await db.prepare('SELECT tag_layout FROM user_preferences LIMIT 1').first() return true } catch (error) { if (error instanceof Error && /no such column: tag_layout/i.test(error.message)) { return false } throw error } } export async function hasSortByColumn(db: D1Database): Promise { try { await db.prepare('SELECT sort_by FROM user_preferences LIMIT 1').first() return true } catch (error) { if (error instanceof Error && /no such column: sort_by/i.test(error.message)) { return false } throw error } } export async function hasAutomationColumns(db: D1Database): Promise { try { await db .prepare('SELECT search_auto_clear_seconds FROM user_preferences LIMIT 1') .first() return true } catch (error) { if ( error instanceof Error && /no such column: search_auto_clear_seconds/i.test(error.message) ) { return false } throw error } } export function mapPreferences(preferences: UserPreferences) { return { theme: preferences.theme, page_size: preferences.page_size, view_mode: preferences.view_mode, density: preferences.density, tag_layout: preferences.tag_layout ?? 'grid', sort_by: preferences.sort_by ?? 'popular', search_auto_clear_seconds: preferences.search_auto_clear_seconds ?? 15, tag_selection_auto_clear_seconds: preferences.tag_selection_auto_clear_seconds ?? 30, enable_search_auto_clear: preferences.enable_search_auto_clear === 1, enable_tag_selection_auto_clear: preferences.enable_tag_selection_auto_clear === 1, default_bookmark_icon: preferences.default_bookmark_icon ?? 'bookmark', snapshot_retention_count: preferences.snapshot_retention_count ?? 5, snapshot_auto_create: preferences.snapshot_auto_create === 1, snapshot_auto_dedupe: preferences.snapshot_auto_dedupe === 1, snapshot_auto_cleanup_days: preferences.snapshot_auto_cleanup_days ?? 0, updated_at: preferences.updated_at, } } export function validatePreferences(body: UpdatePreferencesRequest): string | null { if (body.theme && !['light', 'dark', 'system'].includes(body.theme)) { return 'Invalid theme value' } if (body.page_size && (body.page_size < 10 || body.page_size > 100)) { return 'Page size must be between 10 and 100' } if (body.view_mode && !['list', 'card', 'minimal', 'title'].includes(body.view_mode)) { return 'Invalid view mode' } if (body.density && !['compact', 'normal', 'comfortable'].includes(body.density)) { return 'Invalid density value' } if (body.tag_layout && !['grid', 'masonry'].includes(body.tag_layout)) { return 'Invalid tag layout value' } if (body.sort_by && !['created', 'updated', 'pinned', 'popular'].includes(body.sort_by)) { return 'Invalid sort_by value' } if (body.search_auto_clear_seconds !== undefined && (body.search_auto_clear_seconds < 5 || body.search_auto_clear_seconds > 120)) { return 'Search auto clear seconds must be between 5 and 120' } if (body.tag_selection_auto_clear_seconds !== undefined && (body.tag_selection_auto_clear_seconds < 10 || body.tag_selection_auto_clear_seconds > 300)) { return 'Tag selection auto clear seconds must be between 10 and 300' } if ( body.default_bookmark_icon && !['gradient-glow', 'pulse-breath', 'orbital-spinner', 'bookmark'].includes( body.default_bookmark_icon, ) ) { return 'Invalid default bookmark icon value' } if (body.snapshot_retention_count !== undefined && (body.snapshot_retention_count < -1 || body.snapshot_retention_count > 100)) { return 'Snapshot retention count must be between -1 and 100' } if (body.snapshot_auto_cleanup_days !== undefined && (body.snapshot_auto_cleanup_days < 0 || body.snapshot_auto_cleanup_days > 365)) { return 'Snapshot auto cleanup days must be between 0 and 365' } return null } ================================================ FILE: tmarks/functions/api/v1/preferences.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams, SQLParam } from '../../lib/types' import { success, badRequest, notFound, internalError } from '../../lib/response' import { requireAuth, AuthContext } from '../../middleware/auth' import { UserPreferences, UpdatePreferencesRequest, hasTagLayoutColumn, hasSortByColumn, hasAutomationColumns, mapPreferences, validatePreferences } from './preferences-helpers' // GET /api/v1/preferences - Retrieve user preferences export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const preferences = await context.env.DB.prepare( 'SELECT * FROM user_preferences WHERE user_id = ?' ) .bind(userId) .first() if (!preferences) { return notFound('Preferences not found') } return success({ preferences: mapPreferences(preferences), }) } catch (error) { console.error('Get preferences error:', error) return internalError('Failed to get preferences') } }, ] // PATCH /api/v1/preferences - Update user preferences export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const body = await context.request.json() as UpdatePreferencesRequest const tagLayoutSupported = await hasTagLayoutColumn(context.env.DB) const sortBySupported = await hasSortByColumn(context.env.DB) const automationSupported = await hasAutomationColumns(context.env.DB) const validationError = validatePreferences(body) if (validationError) { return badRequest(validationError) } const updates: string[] = [] const values: SQLParam[] = [] if (body.theme !== undefined) { updates.push('theme = ?') values.push(body.theme) } if (body.page_size !== undefined) { updates.push('page_size = ?') values.push(body.page_size) } if (body.view_mode !== undefined) { updates.push('view_mode = ?') values.push(body.view_mode) } if (body.density !== undefined) { updates.push('density = ?') values.push(body.density) } if (body.tag_layout !== undefined && tagLayoutSupported) { updates.push('tag_layout = ?') values.push(body.tag_layout) } if (body.sort_by !== undefined && sortBySupported) { updates.push('sort_by = ?') values.push(body.sort_by) } if (automationSupported) { if (body.search_auto_clear_seconds !== undefined) { updates.push('search_auto_clear_seconds = ?') values.push(body.search_auto_clear_seconds) } if (body.tag_selection_auto_clear_seconds !== undefined) { updates.push('tag_selection_auto_clear_seconds = ?') values.push(body.tag_selection_auto_clear_seconds) } if (body.enable_search_auto_clear !== undefined) { updates.push('enable_search_auto_clear = ?') values.push(body.enable_search_auto_clear ? 1 : 0) } if (body.enable_tag_selection_auto_clear !== undefined) { updates.push('enable_tag_selection_auto_clear = ?') values.push(body.enable_tag_selection_auto_clear ? 1 : 0) } } if (automationSupported) { if (body.default_bookmark_icon !== undefined) { updates.push('default_bookmark_icon = ?') values.push(body.default_bookmark_icon) } if (body.snapshot_retention_count !== undefined) { updates.push('snapshot_retention_count = ?') values.push(body.snapshot_retention_count) } if (body.snapshot_auto_create !== undefined) { updates.push('snapshot_auto_create = ?') values.push(body.snapshot_auto_create ? 1 : 0) } if (body.snapshot_auto_dedupe !== undefined) { updates.push('snapshot_auto_dedupe = ?') values.push(body.snapshot_auto_dedupe ? 1 : 0) } if (body.snapshot_auto_cleanup_days !== undefined) { updates.push('snapshot_auto_cleanup_days = ?') values.push(body.snapshot_auto_cleanup_days) } } if (updates.length === 0) { if ((body.tag_layout !== undefined && !tagLayoutSupported) || (body.sort_by !== undefined && !sortBySupported)) { const preferences = await context.env.DB.prepare( 'SELECT * FROM user_preferences WHERE user_id = ?' ) .bind(userId) .first() if (!preferences) { return internalError('Failed to load preferences') } return success({ preferences: mapPreferences(preferences), }) } return badRequest('No valid fields to update') } const now = new Date().toISOString() updates.push('updated_at = ?') values.push(now) values.push(userId) const insertStmt = context.env.DB.prepare( `INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT(user_id) DO NOTHING` ).bind(userId) const updateStmt = context.env.DB.prepare( `UPDATE user_preferences SET ${updates.join(', ')} WHERE user_id = ?` ).bind(...values) await context.env.DB.batch([insertStmt, updateStmt]) const preferences = await context.env.DB.prepare( 'SELECT * FROM user_preferences WHERE user_id = ?' ) .bind(userId) .first() if (!preferences) { return internalError('Failed to load preferences after update') } return success({ preferences: mapPreferences(preferences), }) } catch (error) { console.error('Update preferences error:', error) return internalError('Failed to update preferences') } }, ] ================================================ FILE: tmarks/functions/api/v1/settings/api-keys/[id].ts ================================================ /** * API Key * GET /api/v1/settings/api-keys/:id - API Key * PATCH /api/v1/settings/api-keys/:id - API Key * DELETE /api/v1/settings/api-keys/:id - API Key */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams, SQLParam } from '../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' import { getApiKeyStats } from '../../../../lib/api-key/logger' import { PERMISSION_TEMPLATES } from '../../../../../shared/permissions' interface UpdateApiKeyRequest { name?: string description?: string permissions?: string[] template?: 'READ_ONLY' | 'BASIC' | 'FULL' expires_at?: string | null } // GET /api/v1/settings/api-keys/:id - API Key interface ApiKeyDetail { id: string key_prefix: string name: string description: string | null permissions: string status: string expires_at: string | null last_used_at: string | null last_used_ip: string | null created_at: string updated_at: string } export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const keyId = context.params.id try { const keyData = await context.env.DB.prepare( `SELECT id, key_prefix, name, description, permissions, status, expires_at, last_used_at, last_used_ip, created_at, updated_at FROM api_keys WHERE id = ? AND user_id = ?` ) .bind(keyId, userId) .first() if (!keyData) { return notFound('API Key not found') } // const stats = await getApiKeyStats(keyId, context.env.DB) return success({ ...keyData, permissions: JSON.parse(keyData.permissions) as string[], stats, }) } catch (error) { console.error('Failed to get API key:', error) return internalError('Failed to get API key details') } }, ] // PATCH /api/v1/settings/api-keys/:id - API Key export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const keyId = context.params.id try { // 1. Key const existingKey = await context.env.DB.prepare( `SELECT id FROM api_keys WHERE id = ? AND user_id = ?` ) .bind(keyId, userId) .first() if (!existingKey) { return notFound('API Key not found') } // 2. const body = (await context.request.json()) as UpdateApiKeyRequest const { name, description, permissions, expires_at, template } = body const updates: string[] = [] const values: SQLParam[] = [] if (name !== undefined) { if (!name.trim()) { return badRequest({ code: 'INVALID_INPUT', message: 'Name cannot be empty', }) } updates.push('name = ?') values.push(name.trim()) } if (description !== undefined) { updates.push('description = ?') values.push(description?.trim() || null) } if (template || permissions) { let permissionsList: string[] = [] if (template && PERMISSION_TEMPLATES[template]) { permissionsList = PERMISSION_TEMPLATES[template].permissions } else if (permissions && Array.isArray(permissions)) { permissionsList = permissions } if (permissionsList.length > 0) { updates.push('permissions = ?') values.push(JSON.stringify(permissionsList)) } } if (expires_at !== undefined) { if (expires_at === null) { updates.push('expires_at = NULL') } else { let expiresDate: Date // (30d, 90d ) ISO if (expires_at.match(/^\d+d$/)) { const days = parseInt(expires_at.slice(0, -1), 10) expiresDate = new Date() expiresDate.setDate(expiresDate.getDate() + days) } else { expiresDate = new Date(expires_at) } if (expiresDate <= new Date()) { return badRequest({ code: 'INVALID_INPUT', message: 'Expiration date must be in the future', }) } updates.push('expires_at = ?') values.push(expiresDate.toISOString()) } } if (updates.length === 0) { return badRequest({ code: 'INVALID_INPUT', message: 'No valid fields to update', }) } // 3. updates.push("updated_at = datetime('now')") values.push(keyId, userId) await context.env.DB.prepare( `UPDATE api_keys SET ${updates.join(', ')} WHERE id = ? AND user_id = ?` ) .bind(...values) .run() // 4. const updatedKey = await context.env.DB.prepare( `SELECT id, key_prefix, name, description, permissions, status, expires_at, last_used_at, last_used_ip, created_at, updated_at FROM api_keys WHERE id = ?` ) .bind(keyId) .first() if (!updatedKey) { return internalError('Failed to load updated API key') } return success({ ...updatedKey, permissions: JSON.parse(updatedKey.permissions) as string[], }) } catch (error) { console.error('Failed to update API key:', error) return internalError('Failed to update API key') } }, ] // DELETE /api/v1/settings/api-keys/:id - API Key export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const keyId = context.params.id const url = new URL(context.request.url) const hardDelete = url.searchParams.get('hard') === 'true' try { // 1. Key const existingKey = await context.env.DB.prepare( `SELECT id FROM api_keys WHERE id = ? AND user_id = ?` ) .bind(keyId, userId) .first() if (!existingKey) { return notFound('API Key not found') } if (hardDelete) { try { await context.env.DB.batch([ context.env.DB.prepare('DELETE FROM api_key_logs WHERE api_key_id = ?').bind(keyId), context.env.DB.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?').bind(keyId, userId), ]) } catch (error) { console.error('Failed to delete API key records:', error) throw error } return success({ message: 'API Key deleted permanently', }) } // 2. () await context.env.DB.prepare( `UPDATE api_keys SET status = 'revoked', updated_at = datetime('now') WHERE id = ? AND user_id = ?` ) .bind(keyId, userId) .run() return success({ message: 'API Key revoked successfully', }) } catch (error) { console.error('Failed to revoke API key:', error) return internalError('Failed to revoke API key') } }, ] ================================================ FILE: tmarks/functions/api/v1/settings/api-keys/index.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, badRequest, created, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' import { generateApiKey } from '../../../../lib/api-key/generator' import { PERMISSION_TEMPLATES } from '../../../../../shared/permissions' interface CreateApiKeyRequest { name: string description?: string permissions?: string[] template?: 'READ_ONLY' | 'BASIC' | 'FULL' expires_at?: string | null } async function getUserApiKeyLimit(_db: D1Database, _userId: string): Promise { void _db void _userId return 999 } // GET /api/v1/settings/api-keys - Retrieve API keys interface ApiKeyRow { id: string key_prefix: string name: string description: string | null permissions: string status: string expires_at: string | null last_used_at: string | null last_used_ip: string | null created_at: string updated_at: string } export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const keys = await context.env.DB.prepare( `SELECT id, key_prefix, name, description, permissions, status, expires_at, last_used_at, last_used_ip, created_at, updated_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC` ) .bind(userId) .all() const quota = await context.env.DB.prepare( `SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND status = 'active'` ) .bind(userId) .first<{ count: number }>() const used = quota?.count || 0 const limit = await getUserApiKeyLimit(context.env.DB, userId) return success({ keys: (keys.results ?? []).map((key) => ({ ...key, permissions: JSON.parse(key.permissions) as string[], })), quota: { used, limit, }, }) } catch (error) { console.error('Failed to list API keys:', error) return internalError('Failed to list API keys') } }, ] // POST /api/v1/settings/api-keys - Create new API Key export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const quota = await context.env.DB.prepare( `SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND status = 'active'` ) .bind(userId) .first<{ count: number }>() const used = quota?.count || 0 const limit = await getUserApiKeyLimit(context.env.DB, userId) if (used >= limit) { return badRequest({ code: 'QUOTA_EXCEEDED', message: `Maximum ${limit} API keys allowed per user`, quota: { used, limit }, }) } const body = (await context.request.json()) as CreateApiKeyRequest const { name, description, permissions, expires_at, template } = body if (!name || !name.trim()) { return badRequest({ code: 'INVALID_INPUT', message: 'Name is required', }) } let permissionsList: string[] = [] if (template && PERMISSION_TEMPLATES[template]) { permissionsList = PERMISSION_TEMPLATES[template].permissions } else if (permissions && Array.isArray(permissions)) { permissionsList = permissions } else { permissionsList = PERMISSION_TEMPLATES.BASIC.permissions } if (permissionsList.length === 0) { return badRequest({ code: 'INVALID_INPUT', message: 'At least one permission is required', }) } let expiresAt: string | null = null if (expires_at) { let expiresDate: Date if (expires_at.match(/^\d+d$/)) { const days = parseInt(expires_at.slice(0, -1), 10) expiresDate = new Date() expiresDate.setDate(expiresDate.getDate() + days) } else { expiresDate = new Date(expires_at) } if (expiresDate <= new Date()) { return badRequest({ code: 'INVALID_INPUT', message: 'Expiration date must be in the future', }) } expiresAt = expiresDate.toISOString() } const { key, prefix, hash } = await generateApiKey('live') const keyId = crypto.randomUUID() await context.env.DB.prepare( `INSERT INTO api_keys (id, user_id, key_hash, key_prefix, name, description, permissions, status, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)` ) .bind( keyId, userId, hash, prefix, name.trim(), description?.trim() || null, JSON.stringify(permissionsList), expiresAt ) .run() return created({ id: keyId, key, key_prefix: prefix, name: name.trim(), description: description?.trim() || null, permissions: permissionsList, status: 'active', expires_at: expiresAt, created_at: new Date().toISOString(), }) } catch (error) { console.error('Failed to create API key:', error) return internalError('Failed to create API key') } }, ] ================================================ FILE: tmarks/functions/api/v1/settings/share.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { requireAuth, AuthContext } from '../../../middleware/auth' import { success, badRequest, conflict, internalError } from '../../../lib/response' import { sanitizeString } from '../../../lib/validation' import { generateSlug } from '../../../lib/utils' import { invalidatePublicShareCache } from '../../shared/cache' interface UpdateShareSettingsRequest { enabled?: boolean slug?: string | null title?: string | null description?: string | null regenerate_slug?: boolean } const SLUG_MAX_LENGTH = 64 export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const record = await context.env.DB.prepare( `SELECT public_share_enabled, public_slug, public_page_title, public_page_description FROM users WHERE id = ?` ) .bind(userId) .first<{ public_share_enabled: number; public_slug: string | null; public_page_title: string | null; public_page_description: string | null }>() return success({ share: { enabled: Boolean(record?.public_share_enabled), slug: record?.public_slug || null, title: record?.public_page_title || null, description: record?.public_page_description || null, }, }) } catch (error) { console.error('Get share settings error:', error) return internalError('Failed to load share settings') } }, ] export const onRequestPut: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as UpdateShareSettingsRequest const updates: string[] = [] const values: Array = [] let newSlug: string | null | undefined if (body.regenerate_slug) { newSlug = await generateUniqueSlug(context.env, userId) } else if (body.slug !== undefined) { if (body.slug && !/^[a-z0-9-]+$/i.test(body.slug)) { return badRequest('Slug can only contain letters, numbers, and hyphen') } newSlug = body.slug ? sanitizeString(body.slug.toLowerCase(), SLUG_MAX_LENGTH) : null } if (newSlug !== undefined) { if (newSlug) { const exist = await context.env.DB.prepare( `SELECT id FROM users WHERE LOWER(public_slug) = ? AND id != ?` ) .bind(newSlug.toLowerCase(), userId) .first() if (exist) { return conflict('Slug already in use') } } updates.push('public_slug = ?') values.push(newSlug) } if (body.title !== undefined) { updates.push('public_page_title = ?') values.push(body.title ? sanitizeString(body.title, 200) : null) } if (body.description !== undefined) { updates.push('public_page_description = ?') values.push(body.description ? sanitizeString(body.description, 500) : null) } if (body.enabled !== undefined) { updates.push('public_share_enabled = ?') values.push(body.enabled ? 1 : 0) } if (updates.length === 0) { return success({ message: 'No changes applied' }) } updates.push('updated_at = datetime("now")') await context.env.DB.prepare( `UPDATE users SET ${updates.join(', ')} WHERE id = ?` ) .bind(...values, userId) .run() await invalidatePublicShareCache(context.env, userId) const record = await context.env.DB.prepare( `SELECT public_share_enabled, public_slug, public_page_title, public_page_description FROM users WHERE id = ?` ) .bind(userId) .first<{ public_share_enabled: number; public_slug: string | null; public_page_title: string | null; public_page_description: string | null }>() return success({ share: { enabled: Boolean(record?.public_share_enabled), slug: record?.public_slug || null, title: record?.public_page_title || null, description: record?.public_page_description || null, }, }) } catch (error) { console.error('Update share settings error:', error) return internalError('Failed to update share settings') } }, ] async function generateUniqueSlug(env: Env, userId: string): Promise { for (let i = 0; i < 5; i += 1) { const candidate = generateSlug() const existing = await env.DB.prepare( `SELECT id FROM users WHERE LOWER(public_slug) = ? AND id != ?` ) .bind(candidate.toLowerCase(), userId) .first() if (!existing) { return candidate } } throw new Error('Failed to generate unique slug') } ================================================ FILE: tmarks/functions/api/v1/settings/storage.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { requireAuth, AuthContext } from '../../../middleware/auth' import { success, internalError } from '../../../lib/response' import { getCurrentR2UsageBytes, getR2MaxTotalBytes } from '../../../lib/storage-quota' export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { try { const usedBytes = await getCurrentR2UsageBytes(context.env.DB) const limitBytes = getR2MaxTotalBytes(context.env) const unlimited = !Number.isFinite(limitBytes) const safeLimitBytes = unlimited ? null : limitBytes return success({ quota: { used_bytes: usedBytes, limit_bytes: safeLimitBytes, unlimited, }, }) } catch (error) { console.error('Get R2 storage quota error:', error) return internalError('Failed to load storage quota') } }, ] ================================================ FILE: tmarks/functions/api/v1/shared/cache.ts ================================================ // Deprecated shim: keep old import path working but delegate to new implementation export { invalidatePublicShareCache } from '../../shared/cache' ================================================ FILE: tmarks/functions/api/v1/statistics/index.ts ================================================ /** * API * : /api/v1/statistics * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' interface DomainCount { domain: string count: number } // GET /api/v1/statistics - Retrieve user statistics export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const days = parseInt(url.searchParams.get('days') || '30', 10) || 30 const startDate = new Date() startDate.setDate(startDate.getDate() - days) const startDateStr = startDate.toISOString().split('T')[0] try { const [ groupsResult, deletedGroupsResult, itemsResult, sharesResult, groupsTrend, itemsTrend, domains, groupSizes ] = await Promise.all([ context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 0' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 1' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_group_shares WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)' ) .bind(userId) .all<{ count: number }>(), context.env.DB.prepare( `SELECT DATE(created_at) as date, COUNT(*) as count FROM tab_groups WHERE user_id = ? AND DATE(created_at) >= ? GROUP BY DATE(created_at) ORDER BY date ASC` ) .bind(userId, startDateStr) .all<{ date: string; count: number }>(), context.env.DB.prepare( `SELECT DATE(created_at) as date, COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) AND DATE(created_at) >= ? GROUP BY DATE(created_at) ORDER BY date ASC` ) .bind(userId, startDateStr) .all<{ date: string; count: number }>(), context.env.DB.prepare( `SELECT CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1) ELSE SUBSTR(url, INSTR(url, '://') + 3) END as domain, COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) GROUP BY domain ORDER BY count DESC LIMIT 10` ) .bind(userId) .all(), context.env.DB.prepare( `SELECT CASE WHEN item_count = 0 THEN '0' WHEN item_count BETWEEN 1 AND 5 THEN '1-5' WHEN item_count BETWEEN 6 AND 10 THEN '6-10' WHEN item_count BETWEEN 11 AND 20 THEN '11-20' WHEN item_count BETWEEN 21 AND 50 THEN '21-50' ELSE '50+' END as range, COUNT(*) as count FROM ( SELECT tg.id, COUNT(tgi.id) as item_count FROM tab_groups tg LEFT JOIN tab_group_items tgi ON tg.id = tgi.group_id WHERE tg.user_id = ? AND tg.is_deleted = 0 AND tg.is_folder = 0 GROUP BY tg.id ) GROUP BY range ORDER BY CASE range WHEN '0' THEN 0 WHEN '1-5' THEN 1 WHEN '6-10' THEN 2 WHEN '11-20' THEN 3 WHEN '21-50' THEN 4 ELSE 5 END` ) .bind(userId) .all<{ range: string; count: number }>() ]) return success({ summary: { total_groups: groupsResult.results?.[0]?.count || 0, total_deleted_groups: deletedGroupsResult.results?.[0]?.count || 0, total_items: itemsResult.results?.[0]?.count || 0, total_shares: sharesResult.results?.[0]?.count || 0, }, trends: { groups: groupsTrend.results || [], items: itemsTrend.results || [], }, top_domains: domains.results || [], group_size_distribution: groupSizes.results || [], }) } catch (error) { console.error('Get statistics error:', error) return internalError('Failed to get statistics') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/[id]/items/batch.ts ================================================ /** * API - * : /api/v1/tab-groups/:id/items/batch * : JWT Token (Bearer) */ import type { PagesFunction, D1PreparedStatement } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../../lib/response' import { requireAuth, AuthContext } from '../../../../../middleware/auth' import { sanitizeString } from '../../../../../lib/validation' import { generateUUID } from '../../../../../lib/crypto' interface TabGroupRow { id: string user_id: string title: string } interface BatchAddItemsRequest { items: Array<{ title: string url: string favicon?: string }> } interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string } // POST /api/v1/tab-groups/:id/items/batch - export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { const body = (await context.request.json()) as BatchAddItemsRequest if (!body.items || !Array.isArray(body.items) || body.items.length === 0) { return badRequest('items array is required and must not be empty') } // Verify group exists and belongs to user const group = await context.env.DB.prepare( 'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } // Get current max position const maxPositionResult = await context.env.DB.prepare( 'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?' ) .bind(groupId) .first<{ max_position: number | null }>() let currentPosition = (maxPositionResult?.max_position ?? -1) + 1 // Insert items const insertedItems: TabGroupItemRow[] = [] const stmts: D1PreparedStatement[] = [] const now = new Date().toISOString() for (const item of body.items) { if (!item.url || !item.title) { continue // Skip invalid items } const itemId = generateUUID() const sanitizedTitle = sanitizeString(item.title, 500) const sanitizedUrl = sanitizeString(item.url, 2000) const sanitizedFavicon = item.favicon ? sanitizeString(item.favicon, 2000) : null stmts.push( context.env.DB.prepare( `INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)` ).bind(itemId, groupId, sanitizedTitle, sanitizedUrl, sanitizedFavicon, currentPosition, now) ) insertedItems.push({ id: itemId, group_id: groupId, title: sanitizedTitle, url: sanitizedUrl, favicon: sanitizedFavicon, position: currentPosition, created_at: now, }) currentPosition++ } if (stmts.length > 0) { await context.env.DB.batch(stmts) } return success({ message: 'Items added successfully', added_count: insertedItems.length, total_items: currentPosition, items: insertedItems, }) } catch (error) { console.error('Batch add items error:', error) return internalError('Failed to add items to group') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/[id]/permanent-delete.ts ================================================ /** * API * : /api/v1/tab-groups/:id/permanent-delete * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' interface TabGroupRow { id: string user_id: string is_deleted: number } // DELETE /api/v1/tab-groups/:id/permanent-delete - export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Check if group exists and is in trash const group = await context.env.DB.prepare( 'SELECT id, user_id, is_deleted FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } if (group.is_deleted !== 1) { return notFound('Tab group must be in trash before permanent deletion') } // Delete all items in the group await context.env.DB.prepare('DELETE FROM tab_group_items WHERE group_id = ?') .bind(groupId) .run() // Delete the group await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?') .bind(groupId) .run() return success({ message: 'Tab group permanently deleted' }) } catch (error) { console.error('Permanent delete tab group error:', error) return internalError('Failed to permanently delete tab group') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/[id]/restore.ts ================================================ /** * API * : /api/v1/tab-groups/:id/restore * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' interface TabGroupRow { id: string user_id: string is_deleted: number } // POST /api/v1/tab-groups/:id/restore - export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Check if group exists and is deleted const group = await context.env.DB.prepare( 'SELECT id, user_id, is_deleted FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } if (group.is_deleted !== 1) { return success({ message: 'Tab group is not in trash' }) } // Restore the group await context.env.DB.prepare( 'UPDATE tab_groups SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ?' ) .bind(new Date().toISOString(), groupId) .run() return success({ message: 'Tab group restored successfully' }) } catch (error) { console.error('Restore tab group error:', error) return internalError('Failed to restore tab group') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/[id]/share.ts ================================================ /** * API * : /api/v1/tab-groups/:id/share * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' import { generateUUID } from '../../../../lib/crypto' interface TabGroupRow { id: string user_id: string title: string } interface ShareRow { id: string group_id: string share_token: string is_public: number expires_at: string | null created_at: string } interface CreateShareRequest { is_public?: boolean expires_in_days?: number } // POST /api/v1/tab-groups/:id/share - export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { const body = (await context.request.json()) as CreateShareRequest // Verify group exists and belongs to user const group = await context.env.DB.prepare( 'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } // Check if share already exists const existingShare = await context.env.DB.prepare( 'SELECT * FROM tab_group_shares WHERE group_id = ?' ) .bind(groupId) .first() if (existingShare) { // Update existing share const expiresAt = body.expires_in_days ? new Date(Date.now() + body.expires_in_days * 24 * 60 * 60 * 1000).toISOString() : null await context.env.DB.prepare( 'UPDATE tab_group_shares SET is_public = ?, expires_at = ? WHERE id = ?' ) .bind(body.is_public ? 1 : 0, expiresAt, existingShare.id) .run() return success({ share_token: existingShare.share_token, is_public: body.is_public ?? false, expires_at: expiresAt, share_url: `${new URL(context.request.url).origin}/share/${existingShare.share_token}`, }) } // Create new share const shareId = generateUUID() const shareToken = generateUUID() const isPublic = body.is_public ?? false const expiresAt = body.expires_in_days ? new Date(Date.now() + body.expires_in_days * 24 * 60 * 60 * 1000).toISOString() : null const now = new Date().toISOString() await context.env.DB.prepare( `INSERT INTO tab_group_shares (id, group_id, share_token, is_public, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)` ) .bind(shareId, groupId, shareToken, isPublic ? 1 : 0, expiresAt, now) .run() return success({ share_token: shareToken, is_public: isPublic, expires_at: expiresAt, share_url: `${new URL(context.request.url).origin}/share/${shareToken}`, }) } catch (error) { console.error('Create share error:', error) return internalError('Failed to create share') } }, ] // GET /api/v1/tab-groups/:id/share - export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Verify group exists and belongs to user const group = await context.env.DB.prepare( 'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } // Get share info const share = await context.env.DB.prepare( 'SELECT * FROM tab_group_shares WHERE group_id = ?' ) .bind(groupId) .first() if (!share) { return notFound('Share not found') } return success({ share_token: share.share_token, is_public: share.is_public === 1, expires_at: share.expires_at, share_url: `${new URL(context.request.url).origin}/share/${share.share_token}`, created_at: share.created_at, }) } catch (error) { console.error('Get share error:', error) return internalError('Failed to get share info') } }, ] // DELETE /api/v1/tab-groups/:id/share - export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Verify group exists and belongs to user const group = await context.env.DB.prepare( 'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!group) { return notFound('Tab group not found') } // Delete share await context.env.DB.prepare('DELETE FROM tab_group_shares WHERE group_id = ?') .bind(groupId) .run() return success({ message: 'Share deleted successfully' }) } catch (error) { console.error('Delete share error:', error) return internalError('Failed to delete share') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/[id].ts ================================================ /** * API - * : /api/v1/tab-groups/:id * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { success, badRequest, notFound, noContent, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { sanitizeString } from '../../../lib/validation' interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null parent_id: string | null is_folder: number is_deleted: number deleted_at: string | null position: number created_at: string updated_at: string } interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string is_pinned?: number is_todo?: number } interface UpdateTabGroupRequest { title?: string color?: string | null tags?: string[] | null parent_id?: string | null position?: number } // GET /api/v1/tab-groups/:id - export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Get tab group const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found') } // Get tab group items (with user_id verification for security) const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC` ) .bind(groupId, userId) .all() return success({ tab_group: { ...groupRow, items: items || [], item_count: items?.length || 0, }, }) } catch (error) { console.error('Get tab group error:', error) return internalError('Failed to get tab group') } }, ] // PATCH /api/v1/tab-groups/:id - export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { let body: UpdateTabGroupRequest try { body = (await context.request.json()) as UpdateTabGroupRequest } catch (parseError) { console.error('Failed to parse request body:', parseError) return badRequest('Invalid request body: ' + (parseError instanceof Error ? parseError.message : 'JSON parse error')) } // Check if tab group exists and belongs to user const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found') } // Build update query const updates: string[] = [] const params: Array = [] if (body.title !== undefined) { updates.push('title = ?') params.push(sanitizeString(body.title, 200)) } if (body.parent_id !== undefined) { updates.push('parent_id = ?') params.push(body.parent_id) } if (body.position !== undefined) { updates.push('position = ?') params.push(body.position) } if (body.color !== undefined) { updates.push('color = ?') params.push(body.color) } if (body.tags !== undefined) { updates.push('tags = ?') params.push(body.tags ? JSON.stringify(body.tags) : null) } if (updates.length === 0) { return badRequest('No valid fields to update') } // Always update updated_at updates.push('updated_at = ?') params.push(new Date().toISOString()) // Add WHERE clause params params.push(groupId, userId) await context.env.DB.prepare( `UPDATE tab_groups SET ${updates.join(', ')} WHERE id = ? AND user_id = ?` ) .bind(...params) .run() // Fetch updated group with items const updatedGroup = await context.env.DB.prepare('SELECT * FROM tab_groups WHERE id = ?') .bind(groupId) .first() const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY tgi.position ASC` ) .bind(groupId, userId) .all() if (!updatedGroup) { return internalError('Failed to load tab group after update') } return success({ tab_group: { ...updatedGroup, items: items || [], item_count: items?.length || 0, }, }) } catch (error) { console.error('Update tab group error:', error) return internalError('Failed to update tab group') } }, ] // DELETE /api/v1/tab-groups/:id - export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const groupId = context.params.id try { // Check if tab group exists and belongs to user const groupRow = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(groupId, userId) .first() if (!groupRow) { return notFound('Tab group not found') } // Delete tab group (items will be cascade deleted) await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ? AND user_id = ?') .bind(groupId, userId) .run() return noContent() } catch (error) { console.error('Delete tab group error:', error) return internalError('Failed to delete tab group') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/batch-update.ts ================================================ /** * PATCH /api/v1/tab-groups/batch-update * Batch update tab group positions */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../lib/types' import { success, badRequest, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' interface BatchUpdateItem { id: string position: number parent_id: string | null } interface BatchUpdateRequest { updates: BatchUpdateItem[] } export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as BatchUpdateRequest if (!body.updates || !Array.isArray(body.updates) || body.updates.length === 0) { return badRequest('updates array is required') } if (body.updates.length > 200) { return badRequest('Cannot update more than 200 items at once') } const db = context.env.DB const stmts = body.updates.map(item => db.prepare( `UPDATE tab_groups SET position = ?, parent_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?` ).bind(item.position, item.parent_id, item.id, userId) ) await db.batch(stmts) return success({ message: 'Batch update successful', updated_count: body.updates.length, }) } catch (error) { console.error('Batch update tab groups error:', error) return internalError('Failed to batch update tab groups') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/index.ts ================================================ /** * API - * : /api/v1/tab-groups * : JWT Token */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams, SQLParam } from '../../../lib/types' import { success, created, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null parent_id: string | null is_folder: number is_deleted: number deleted_at: string | null position: number created_at: string updated_at: string } interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string is_pinned?: number is_todo?: number } interface CreateTabGroupRequest { title?: string parent_id?: string | null is_folder?: boolean items?: Array<{ title: string url: string favicon?: string }> } // GET /api/v1/tab-groups export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const url = new URL(context.request.url) const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100) const pageCursor = url.searchParams.get('page_cursor') || '' try { let query = ` SELECT * FROM tab_groups WHERE user_id = ? AND (is_deleted IS NULL OR is_deleted = 0) ` const params: SQLParam[] = [userId] if (pageCursor) { query += ' AND created_at < ?' params.push(pageCursor) } query += ' ORDER BY created_at DESC LIMIT ?' params.push(pageSize + 1) const { results } = await context.env.DB.prepare(query) .bind(...params) .all() const hasMore = results.length > pageSize const tabGroups = hasMore ? results.slice(0, pageSize) : results const nextCursor = hasMore ? tabGroups[tabGroups.length - 1].created_at : null // Batch fetch all items for returned groups (avoids N+1) const groupIds = tabGroups.map((g) => g.id) let allItems: TabGroupItemRow[] = [] if (groupIds.length > 0) { const placeholders = groupIds.map(() => '?').join(',') const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id IN (${placeholders}) AND tg.user_id = ? ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC` ) .bind(...groupIds, userId) .all() allItems = items || [] } // Group items by group_id in memory const itemsByGroup = new Map() for (const item of allItems) { const arr = itemsByGroup.get(item.group_id) || [] arr.push(item) itemsByGroup.set(item.group_id, arr) } const groupsWithItems = tabGroups.map((group) => { const items = itemsByGroup.get(group.id) || [] return { ...group, items, item_count: items.length, } }) return success({ tab_groups: groupsWithItems, meta: { page_size: pageSize, count: tabGroups.length, next_cursor: nextCursor, has_more: hasMore, }, }) } catch (error) { console.error('Get tab groups error:', error) return internalError('Failed to get tab groups') } }, ] // POST /api/v1/tab-groups export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const body = (await context.request.json()) as CreateTabGroupRequest const isFolder = body.is_folder || false const now = new Date() const defaultTitle = body.title || (isFolder ? '' : now.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).replace(/\//g, '-')) const title = sanitizeString(defaultTitle, 200) const groupId = generateUUID() const timestamp = now.toISOString() const parentId = body.parent_id || null // Build all statements for atomic batch execution const stmts = [ context.env.DB.prepare( 'INSERT INTO tab_groups (id, user_id, title, parent_id, is_folder, is_deleted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)' ).bind(groupId, userId, title, parentId, isFolder ? 1 : 0, timestamp, timestamp), ] if (!isFolder && body.items && body.items.length > 0) { for (let i = 0; i < body.items.length; i++) { const item = body.items[i] const itemId = generateUUID() const itemTitle = sanitizeString(item.title, 500) const itemUrl = sanitizeString(item.url, 2000) const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null stmts.push( context.env.DB.prepare( 'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ).bind(itemId, groupId, itemTitle, itemUrl, favicon, i, timestamp) ) } } await context.env.DB.batch(stmts) // Fetch the created group with items const groupRow = await context.env.DB.prepare('SELECT * FROM tab_groups WHERE id = ?') .bind(groupId) .first() const { results: items } = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.group_id = ? AND tg.user_id = ? ORDER BY tgi.position ASC` ) .bind(groupId, userId) .all() if (!groupRow) { return internalError('Failed to load tab group after creation') } return created({ tab_group: { ...groupRow, items: items || [], item_count: items?.length || 0, }, }) } catch (error) { console.error('Create tab group error:', error) return internalError('Failed to create tab group') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/items/[id]/move.ts ================================================ /** * API - * : /api/v1/tab-groups/items/:id/move * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../../lib/response' import { requireAuth, AuthContext } from '../../../../../middleware/auth' interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string is_pinned?: number is_todo?: number } interface MoveItemRequest { target_group_id: string position?: number } // POST /api/v1/tab-groups/items/:id/move - export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const itemId = context.params.id try { const body = (await context.request.json()) as MoveItemRequest if (!body.target_group_id) { return badRequest('target_group_id is required') } // 1. const item = await context.env.DB.prepare( `SELECT tgi.*, tg.user_id FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ?` ) .bind(itemId) .first() if (!item) { return notFound('Tab group item not found') } if (item.user_id !== userId) { return notFound('Tab group item not found') } // 2. const targetGroup = await context.env.DB.prepare( 'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?' ) .bind(body.target_group_id, userId) .first<{ id: string; user_id: string }>() if (!targetGroup) { return notFound('Target group not found') } // 3. , if (item.group_id === body.target_group_id) { if (body.position !== undefined) { // await context.env.DB.prepare( 'UPDATE tab_group_items SET position = ? WHERE id = ?' ) .bind(body.position, itemId) .run() // await context.env.DB.prepare( `UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ? AND position >= ?` ) .bind(item.group_id, itemId, body.position) .run() } } else { // 4. // 4.1 const maxPositionResult = await context.env.DB.prepare( 'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?' ) .bind(body.target_group_id) .first<{ max_position: number | null }>() const targetPosition = body.position !== undefined ? body.position : (maxPositionResult?.max_position ?? -1) + 1 // 4.2 await context.env.DB.prepare( `UPDATE tab_group_items SET group_id = ?, position = ? WHERE id = ?` ) .bind(body.target_group_id, targetPosition, itemId) .run() // 4.3 () await context.env.DB.prepare( `UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?` ) .bind(item.group_id, item.position) .run() // 4.4 () if (body.position !== undefined) { await context.env.DB.prepare( `UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ? AND position >= ?` ) .bind(body.target_group_id, itemId, targetPosition) .run() } } // 5. const updatedItem = await context.env.DB.prepare( 'SELECT * FROM tab_group_items WHERE id = ?' ) .bind(itemId) .first() if (!updatedItem) { return internalError('Failed to load item after move') } return success({ item: updatedItem, message: 'Item moved successfully', }) } catch (error) { console.error('Move tab group item error:', error) return internalError('Failed to move tab group item') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/items/[id].ts ================================================ /** * API - * : /api/v1/tab-groups/items/:id * : JWT Token (Bearer) */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../../../../lib/types' import { success, badRequest, notFound, internalError } from '../../../../lib/response' import { requireAuth, AuthContext } from '../../../../middleware/auth' import { sanitizeString } from '../../../../lib/validation' interface TabGroupItemRow { id: string group_id: string title: string url: string favicon: string | null position: number created_at: string is_pinned?: number is_todo?: number is_archived?: number } interface UpdateTabGroupItemRequest { title?: string is_pinned?: boolean is_todo?: boolean is_archived?: boolean position?: number } // PATCH /api/v1/tab-groups/items/:id - export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const itemId = context.params.id try { const body = (await context.request.json()) as UpdateTabGroupItemRequest // Check if item exists and user has permission const item = await context.env.DB.prepare( `SELECT tgi.*, tg.user_id FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ?` ) .bind(itemId) .first() if (!item) { return notFound('Tab group item not found') } if (item.user_id !== userId) { return notFound('Tab group item not found') } // Build update query const updates: string[] = [] const params: (string | number)[] = [] if (body.title !== undefined) { updates.push('title = ?') params.push(sanitizeString(body.title, 500)) } if (body.is_pinned !== undefined) { updates.push('is_pinned = ?') params.push(body.is_pinned ? 1 : 0) // If pinning, set position to 0 and shift others if (body.is_pinned) { await context.env.DB.prepare( 'UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ?' ) .bind(item.group_id, itemId) .run() updates.push('position = ?') params.push(0) } } if (body.is_todo !== undefined) { updates.push('is_todo = ?') params.push(body.is_todo ? 1 : 0) } if (body.is_archived !== undefined) { updates.push('is_archived = ?') params.push(body.is_archived ? 1 : 0) } if (body.position !== undefined) { updates.push('position = ?') params.push(body.position) } if (updates.length === 0) { return badRequest('No fields to update') } // Add item ID to params — use subquery to enforce ownership params.push(itemId, item.group_id, userId) // Execute update with ownership check via group await context.env.DB.prepare( `UPDATE tab_group_items SET ${updates.join(', ')} WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)` ) .bind(...params) .run() // Get updated item const updatedItem = await context.env.DB.prepare( `SELECT tgi.* FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ? AND tg.user_id = ?` ) .bind(itemId, userId) .first() if (!updatedItem) { return internalError('Failed to load item after update') } return success({ item: updatedItem, message: 'Item updated successfully', }) } catch (error) { console.error('Update tab group item error:', error) return internalError('Failed to update tab group item') } }, ] // DELETE /api/v1/tab-groups/items/:id - export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id const itemId = context.params.id try { // Check if item exists and user has permission const item = await context.env.DB.prepare( `SELECT tgi.*, tg.user_id FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tgi.id = ?` ) .bind(itemId) .first() if (!item) { return notFound('Tab group item not found') } if (item.user_id !== userId) { return notFound('Tab group item not found') } // Delete item with ownership check await context.env.DB.prepare( 'DELETE FROM tab_group_items WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)' ) .bind(itemId, item.group_id, userId) .run() // Reorder remaining items await context.env.DB.prepare( 'UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?' ) .bind(item.group_id, item.position) .run() return success({ message: 'Item deleted successfully', }) } catch (error) { console.error('Delete tab group item error:', error) return internalError('Failed to delete tab group item') } }, ] ================================================ FILE: tmarks/functions/api/v1/tab-groups/trash.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from '../../../lib/types' import { success, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' interface TabGroupRow { id: string user_id: string title: string color: string | null tags: string | null parent_id: string | null is_folder: number is_deleted: number deleted_at: string | null position: number created_at: string updated_at: string } // GET /api/v1/tab-groups/trash - Retrieve trashed tab groups export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { const userId = context.data.user_id try { const { results: groups } = await context.env.DB.prepare( 'SELECT * FROM tab_groups WHERE user_id = ? AND is_deleted = 1 ORDER BY deleted_at DESC' ) .bind(userId) .all() const groupsWithCounts = await Promise.all( (groups || []).map(async (group) => { const { results: items } = await context.env.DB.prepare( 'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id = ?' ) .bind(group.id) .all<{ count: number }>() let tags: string[] | null = null if (group.tags) { try { tags = JSON.parse(group.tags) } catch { tags = null } } return { ...group, tags, item_count: items?.[0]?.count || 0, } }) ) return success({ tab_groups: groupsWithCounts, total: groupsWithCounts.length, }) } catch (error) { console.error('Get trash error:', error) return internalError('Failed to load trash') } }, ] ================================================ FILE: tmarks/functions/api/v1/tags/[id].ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, Tag, RouteParams, SQLParam } from '../../../lib/types' import { success, badRequest, notFound, noContent, conflict, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { sanitizeString } from '../../../lib/validation' interface UpdateTagRequest { name?: string color?: string } // PATCH /api/v1/tags/:id - export const onRequestPatch: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const tagId = context.params.id const body = await context.request.json() as UpdateTagRequest // const tag = await context.env.DB.prepare( 'SELECT * FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(tagId, userId) .first() if (!tag) { return notFound('Tag not found') } const updates: string[] = [] const values: SQLParam[] = [] if (body.name !== undefined) { const name = sanitizeString(body.name, 50) // const existing = await context.env.DB.prepare( 'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL' ) .bind(userId, name, tagId) .first() if (existing) { return conflict('Tag with this name already exists') } updates.push('name = ?') values.push(name) } if (body.color !== undefined) { updates.push('color = ?') values.push(body.color ? sanitizeString(body.color, 20) : null) } if (updates.length === 0) { return badRequest('No valid fields to update') } const now = new Date().toISOString() updates.push('updated_at = ?') values.push(now) values.push(tagId, userId) await context.env.DB.prepare(`UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`) .bind(...values) .run() // const updatedTag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?') .bind(tagId, userId) .first() return success({ tag: updatedTag }) } catch (error) { console.error('Update tag error:', error) return internalError('Failed to update tag') } }, ] // DELETE /api/v1/tags/:id - () export const onRequestDelete: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const tagId = context.params.id // const tag = await context.env.DB.prepare( 'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) .bind(tagId, userId) .first() if (!tag) { return notFound('Tag not found') } const now = new Date().toISOString() // await context.env.DB.prepare('UPDATE tags SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?') .bind(now, now, tagId, userId) .run() // - await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE tag_id = ? AND user_id = ?') .bind(tagId, userId) .run() return noContent() } catch (error) { console.error('Delete tag error:', error) return internalError('Failed to delete tag') } }, ] ================================================ FILE: tmarks/functions/api/v1/tags/index.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, Tag, RouteParams } from '../../../lib/types' import { success, badRequest, created, conflict, internalError } from '../../../lib/response' import { requireAuth, AuthContext } from '../../../middleware/auth' import { sanitizeString } from '../../../lib/validation' import { generateUUID } from '../../../lib/crypto' interface CreateTagRequest { name: string color?: string } interface TagWithCount extends Tag { bookmark_count: number } // GET /api/v1/tags - Retrieve all tags export const onRequestGet: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const url = new URL(context.request.url) const sortBy = url.searchParams.get('sort') || 'usage' let query = ` SELECT t.*, COUNT(bt.bookmark_id) as bookmark_count FROM tags t LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id WHERE t.user_id = ? AND t.deleted_at IS NULL GROUP BY t.id ` if (sortBy === 'name') { query += ' ORDER BY LOWER(t.name) ASC' } else if (sortBy === 'clicks') { query += ' ORDER BY t.click_count DESC, LOWER(t.name) ASC' } else { query += ' ORDER BY bookmark_count DESC, LOWER(t.name) ASC' } const { results } = await context.env.DB.prepare(query) .bind(userId) .all() return success({ tags: results || [], }) } catch (error) { console.error('Get tags error:', error) return internalError('Failed to get tags') } }, ] // POST /api/v1/tags - Create a new tag export const onRequestPost: PagesFunction[] = [ requireAuth, async (context) => { try { const userId = context.data.user_id const body = await context.request.json() as CreateTagRequest if (!body.name) { return badRequest('Tag name is required') } const name = sanitizeString(body.name, 50) const color = body.color ? sanitizeString(body.color, 20) : null const existing = await context.env.DB.prepare( 'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND deleted_at IS NULL' ) .bind(userId, name) .first() if (existing) { return conflict('Tag with this name already exists') } const now = new Date().toISOString() const tagUuid = generateUUID() await context.env.DB.prepare( `INSERT INTO tags (id, user_id, name, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)` ) .bind(tagUuid, userId, name, color, now, now) .run() const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ?') .bind(tagUuid) .first() return created({ tag }) } catch (error) { console.error('Create tag error:', error) return internalError('Failed to create tag') } }, ] ================================================ FILE: tmarks/functions/lib/api-key/generator.ts ================================================ /** * : tmk_live_[20] */ /** * API Key * @param env ('live' | 'test') * @returns API Key, , SHA256 */ export async function generateApiKey(env: 'live' | 'test' = 'live'): Promise<{ key: string prefix: string hash: string }> { const base62Chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' const randomBytes = new Uint8Array(20) crypto.getRandomValues(randomBytes) const randomStr = Array.from(randomBytes) .map(byte => base62Chars[byte % base62Chars.length]) .join('') const key = `tmk_${env}_${randomStr}` const prefix = key.substring(0, 13) // tmk_live_1a2b // SHA256 const hash = await hashApiKey(key) return { key, prefix, hash } } /** * SHA256 * @param key API Key * @returns SHA256 (hex) */ export async function hashApiKey(key: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(key) const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = new Uint8Array(hashBuffer) const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') return hashHex } /** * API Key */ export function isValidApiKeyFormat(key: string): boolean { // : tmk_(live|test)_[20base62] const pattern = /^tmk_(live|test)_[a-zA-Z0-9]{20}$/ return pattern.test(key) } ================================================ FILE: tmarks/functions/lib/api-key/logger.ts ================================================ /** * API Key Logger - Records API key usage and provides statistics * Automatically cleans up old logs, keeping only the latest 100 entries per key */ interface LogEntry { api_key_id: string user_id: string endpoint: string method: string status: number ip: string | null } /** * Log API Key usage * @param entry Log entry data * @param db D1 Database */ export async function logApiKeyUsage(entry: LogEntry, db: D1Database): Promise { try { await db .prepare( `INSERT INTO api_key_logs (api_key_id, user_id, endpoint, method, status, ip) VALUES (?, ?, ?, ?, ?, ?)` ) .bind( entry.api_key_id, entry.user_id, entry.endpoint, entry.method, entry.status, entry.ip ) .run() // Async cleanup (keep only latest 100 logs per key) await cleanupOldLogs(entry.api_key_id, db) } catch (error) { // Don't throw error, just log it console.error('Failed to log API key usage:', error) } } /** * Cleanup old logs, keep only the latest 100 entries * @param apiKeyId API Key ID * @param db D1 Database */ async function cleanupOldLogs(apiKeyId: string, db: D1Database): Promise { try { // Keep only the latest 100 logs await db .prepare( `DELETE FROM api_key_logs WHERE api_key_id = ? AND id NOT IN ( SELECT id FROM api_key_logs WHERE api_key_id = ? ORDER BY created_at DESC LIMIT 100 )` ) .bind(apiKeyId, apiKeyId) .run() } catch (error) { console.error('Failed to cleanup old logs:', error) } } /** * Get API Key usage logs * @param apiKeyId API Key ID * @param limit Maximum number of logs to return (default: 10) * @param db D1 Database * @returns Array of log entries */ export async function getApiKeyLogs( apiKeyId: string, db: D1Database, limit: number = 10 ): Promise { const result = await db .prepare( `SELECT api_key_id, user_id, endpoint, method, status, ip, created_at FROM api_key_logs WHERE api_key_id = ? ORDER BY created_at DESC LIMIT ?` ) .bind(apiKeyId, limit) .all() return result.results as unknown as LogEntry[] } /** * Get API Key usage statistics * @param apiKeyId API Key ID * @param db D1 Database * @returns Statistics object with total requests, last used time and IP */ export async function getApiKeyStats( apiKeyId: string, db: D1Database ): Promise<{ total_requests: number last_used_at: string | null last_used_ip: string | null }> { const result = await db .prepare( `SELECT COUNT(*) as total_requests, MAX(created_at) as last_used_at, (SELECT ip FROM api_key_logs WHERE api_key_id = ? ORDER BY created_at DESC LIMIT 1) as last_used_ip FROM api_key_logs WHERE api_key_id = ?` ) .bind(apiKeyId, apiKeyId) .first() return result as unknown as { total_requests: number last_used_at: string | null last_used_ip: string | null } } ================================================ FILE: tmarks/functions/lib/api-key/rate-limiter-types.ts ================================================ /** * API Key Rate Limiter Types */ export type RateLimitWindow = 'minute' | 'hour' | 'day'; export interface RateLimitConfig { per_minute: number; per_hour: number; per_day: number; } // Default limits: reasonable for normal use, low enough to deter abuse. export const DEFAULT_LIMITS: RateLimitConfig = { per_minute: 60, per_hour: 1000, per_day: 10000, }; export interface RateLimitResult { allowed: boolean; window: RateLimitWindow; limit: number; remaining: number; reset: number; // unix ms retryAfter?: number; // seconds } ================================================ FILE: tmarks/functions/lib/api-key/rate-limiter.ts ================================================ /** * API Key Rate Limiter (D1-backed) */ import { RateLimitWindow, RateLimitConfig, RateLimitResult, DEFAULT_LIMITS } from './rate-limiter-types'; let ensureTablePromise: Promise | null = null; async function ensureTable(db: D1Database): Promise { if (ensureTablePromise) return ensureTablePromise; ensureTablePromise = db .prepare( `CREATE TABLE IF NOT EXISTS api_key_rate_limits ( api_key_id TEXT NOT NULL, window TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (api_key_id, window, window_start) )` ) .run() .then(() => undefined) .catch(() => undefined); return ensureTablePromise; } function getWindowMs(window: RateLimitWindow): number { if (window === 'minute') return 60_000; if (window === 'hour') return 3_600_000; return 86_400_000; } function getLimit(limits: RateLimitConfig, window: RateLimitWindow): number { if (window === 'minute') return limits.per_minute; if (window === 'hour') return limits.per_hour; return limits.per_day; } function getWindowStart(now: number, windowMs: number): number { return Math.floor(now / windowMs) * windowMs; } async function maybeCleanup(db: D1Database, now: number): Promise { // Use a consistent 1% probability for cleanup if (Math.random() < 0.01) { const cutoff = now - 7 * 86_400_000; await db.prepare(`DELETE FROM api_key_rate_limits WHERE updated_at < ?`).bind(cutoff).run(); } } async function getCounts( db: D1Database, apiKeyId: string, windows: Array<{ window: RateLimitWindow; windowStart: number }> ): Promise> { const counts = new Map(); windows.forEach((w) => counts.set(w.window, 0)); const minute = windows.find((w) => w.window === 'minute')!; const hour = windows.find((w) => w.window === 'hour')!; const day = windows.find((w) => w.window === 'day')!; const result = await db .prepare( `SELECT window, count FROM api_key_rate_limits WHERE api_key_id = ? AND ( (window = 'minute' AND window_start = ?) OR (window = 'hour' AND window_start = ?) OR (window = 'day' AND window_start = ?) )` ) .bind(apiKeyId, minute.windowStart, hour.windowStart, day.windowStart) .all<{ window: RateLimitWindow; count: number }>(); (result.results || []).forEach((row) => { counts.set(row.window, Number(row.count) || 0); }); return counts; } /** * Check rate limit without incrementing counters. */ export async function checkRateLimit( apiKeyId: string, db: D1Database, limits: RateLimitConfig = DEFAULT_LIMITS ): Promise { const now = Date.now(); try { await ensureTable(db); const windows: Array<{ window: RateLimitWindow; windowStart: number }> = (['minute', 'hour', 'day'] as const).map((w) => { const windowMs = getWindowMs(w); return { window: w, windowStart: getWindowStart(now, windowMs) }; }); const counts = await getCounts(db, apiKeyId, windows); let minuteAllowedResult: RateLimitResult | null = null; for (const w of ['minute', 'hour', 'day'] as const) { const limit = getLimit(limits, w); const current = counts.get(w) || 0; const windowMs = getWindowMs(w); const windowStart = windows.find((x) => x.window === w)!.windowStart; const reset = windowStart + windowMs; const remaining = Math.max(0, limit - current); if (current >= limit) { return { allowed: false, window: w, limit, remaining: 0, reset, retryAfter: Math.max(0, Math.ceil((reset - now) / 1000)), }; } if (w === 'minute') { minuteAllowedResult = { allowed: true, window: w, limit, remaining, reset, }; } } return ( minuteAllowedResult || { allowed: true, window: 'minute', limit: limits.per_minute, remaining: limits.per_minute, reset: now + 60_000, } ); } catch { // Fail-open to avoid accidental outage. return { allowed: true, window: 'minute', limit: limits.per_minute, remaining: limits.per_minute, reset: now + 60_000, }; } } /** * Atomically consume one request from all rate-limit windows if allowed. */ export async function consumeRateLimit( apiKeyId: string, db: D1Database, limits: RateLimitConfig = DEFAULT_LIMITS ): Promise { // 1. Check current limits const result = await checkRateLimit(apiKeyId, db, limits); // 2. Only increment counters AFTER confirming the request is allowed if (result.allowed) { try { await recordRequest(apiKeyId, db); // Return the result with decremented remaining count return { ...result, remaining: Math.max(0, result.remaining - 1), }; } catch { // If recording fails, still allow the request (fail-open) return result; } } return result; } /** * Record a request by incrementing all counters. */ export async function recordRequest( apiKeyId: string, db: D1Database ): Promise { const now = Date.now(); await ensureTable(db); const windows: RateLimitWindow[] = ['minute', 'hour', 'day']; const statements = windows.map((w) => { const windowMs = getWindowMs(w); const windowStart = getWindowStart(now, windowMs); return db .prepare( `INSERT INTO api_key_rate_limits (api_key_id, window, window_start, count, updated_at) VALUES (?, ?, ?, 1, ?) ON CONFLICT(api_key_id, window, window_start) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at` ) .bind(apiKeyId, w, windowStart, now); }); // D1 batch is best-effort here const anyDb = db as unknown as { batch?: (stmts: unknown[]) => Promise }; if (typeof anyDb.batch === 'function') { await anyDb.batch(statements); } else { for (const stmt of statements) { await stmt.run(); } } // Opportunistic cleanup await maybeCleanup(db, now); } ================================================ FILE: tmarks/functions/lib/api-key/validator.ts ================================================ /** * API Key Validator */ import { hashApiKey } from './generator' import { hasPermission } from '../../../shared/permissions' interface ApiKeyData { id: string user_id: string permissions: string // JSON string status: 'active' | 'revoked' | 'expired' expires_at: string | null last_used_at: string | null last_used_ip: string | null } interface ValidationResult { valid: boolean error?: string data?: ApiKeyData permissions?: string[] } /** * Validate API Key * @param apiKey API Key string * @param db D1 Database instance * @returns Validation result */ export async function validateApiKey( apiKey: string, db: D1Database ): Promise { // 1. Validate format if (!apiKey || !apiKey.startsWith('tmk_')) { return { valid: false, error: 'Invalid API Key format' } } try { // 2. Hash and query database const keyHash = await hashApiKey(apiKey) const keyData = await db .prepare( `SELECT id, user_id, permissions, status, expires_at, last_used_at, last_used_ip FROM api_keys WHERE key_hash = ?` ) .bind(keyHash) .first() if (!keyData) { return { valid: false, error: 'API Key not found' } } // 3. Check if revoked if (keyData.status === 'revoked') { return { valid: false, error: 'API Key has been revoked' } } // 4. Check if expired if (keyData.status === 'expired') { return { valid: false, error: 'API Key has expired' } } // 5. Check expiration date if (keyData.expires_at) { const expiresAt = new Date(keyData.expires_at) if (expiresAt < new Date()) { await markAsExpired(keyData.id, db) return { valid: false, error: 'API Key has expired' } } } // 6. Parse permissions const permissions = JSON.parse(keyData.permissions) as string[] return { valid: true, data: keyData, permissions, } } catch (error) { console.error('API Key validation error:', error) return { valid: false, error: 'Internal validation error' } } } /** * Check if permissions include required permission * @param permissions Array of permission strings * @param requiredPermission Required permission to check * @returns True if permission is granted */ export function checkPermission(permissions: string[], requiredPermission: string): boolean { return hasPermission(permissions, requiredPermission) } /** * Mark API Key as expired * @param keyId API Key ID * @param db D1 Database instance */ async function markAsExpired(keyId: string, db: D1Database): Promise { await db .prepare( `UPDATE api_keys SET status = 'expired', updated_at = datetime('now') WHERE id = ?` ) .bind(keyId) .run() } /** * Update last used timestamp and IP address * @param keyId API Key ID * @param ip Client IP address * @param db D1 Database instance */ export async function updateLastUsed( keyId: string, ip: string | null, db: D1Database ): Promise { await db .prepare( `UPDATE api_keys SET last_used_at = datetime('now'), last_used_ip = ?, updated_at = datetime('now') WHERE id = ?` ) .bind(ip, keyId) .run() } ================================================ FILE: tmarks/functions/lib/bookmark-utils.ts ================================================ import type { Bookmark, BookmarkRow } from './types' /** * Bookmark */ export function normalizeBookmark(row: BookmarkRow): Bookmark { return { ...row, is_pinned: Boolean(row.is_pinned), is_archived: Boolean(row.is_archived), is_public: Boolean(row.is_public), click_count: Number(row.click_count || 0), has_snapshot: Boolean(row.has_snapshot), snapshot_count: Number(row.snapshot_count || 0), } } ================================================ FILE: tmarks/functions/lib/cache/README.md ================================================ # TMarks 缓存系统 ## 📖 概述 TMarks 缓存系统提供灵活、强健、成本可控的多层缓存解决方案。 ### 核心特性 - ✅ **4 级配置** - 从无缓存到激进缓存 - ✅ **批量操作零成本** - 批量导入不写缓存 - ✅ **优雅降级** - KV 故障自动降级到 D1 - ✅ **多层缓存** - 内存 + KV + D1 - ✅ **模块化设计** - 易于维护和扩展 ## 🏗️ 架构 ``` 用户请求 ↓ L1: Worker 内存缓存 (<1ms) ↓ 未命中 L2: KV 边缘缓存 (<10ms) ↓ 未命中 L3: D1 数据库 (50-200ms) ``` ## 📁 文件结构 ``` cache/ ├── types.ts # 类型定义 ├── config.ts # 配置管理 (4 级预设) ├── strategies.ts # 缓存策略 (键生成、判断) ├── service.ts # 核心服务 (多层缓存、降级) ├── bookmark-cache.ts # 书签缓存封装 ├── index.ts # 导出接口 └── README.md # 本文档 ``` ## 🚀 快速开始 ### 1. 配置 ```toml # wrangler.toml [vars] CACHE_LEVEL = "1" # 0-3 ENABLE_KV_CACHE = "true" ``` ### 2. 使用 ```typescript import { CacheService } from './lib/cache' import { createBookmarkCacheManager } from './lib/cache/bookmark-cache' // 初始化 const cache = new CacheService(env) const bookmarkCache = createBookmarkCacheManager(cache) // 获取缓存 const cached = await bookmarkCache.getBookmarkList(userId, params) if (cached) return success(cached) // 查询数据库 const data = await queryDB(...) // 写入缓存 (异步) await bookmarkCache.setBookmarkList(userId, params, data, { async: true }) ``` ## ⚙️ 配置级别 | 级别 | 说明 | 月成本 | 响应时间 | 命中率 | |------|------|--------|----------|--------| | 0 | 无缓存 | ~$5 | 100-300ms | 0% | | 1 | 最小缓存 ⭐ | ~$8 | 50-100ms | 60-70% | | 2 | 标准缓存 | ~$12 | 30-50ms | 80-85% | | 3 | 激进缓存 | ~$20 | 20-30ms | 90-95% | ### Level 0: 无缓存 ```typescript strategies: { rateLimit: true, // 仅速率限制 publicShare: false, defaultList: false, tagFilter: false, search: false, complexQuery: false, } ``` ### Level 1: 最小缓存 (推荐默认) ```typescript strategies: { rateLimit: true, publicShare: true, defaultList: true, // 缓存默认列表 tagFilter: false, search: false, complexQuery: false, } ``` ### Level 2: 标准缓存 (推荐生产) ```typescript strategies: { rateLimit: true, publicShare: true, defaultList: true, tagFilter: true, // 缓存标签筛选 search: false, complexQuery: false, } memoryCache: { enabled: true, // 启用内存缓存 maxAge: 60, } ``` ### Level 3: 激进缓存 ```typescript strategies: { rateLimit: true, publicShare: true, defaultList: true, tagFilter: true, search: true, // 缓存搜索 complexQuery: true, // 缓存复杂查询 } ``` ## 🔧 API 参考 ### CacheService 核心缓存服务类。 ```typescript class CacheService { // 获取缓存 async get(type: CacheStrategyType, key: string): Promise // 设置缓存 async set(type: CacheStrategyType, key: string, data: T, options?: CacheSetOptions): Promise // 删除缓存 async delete(key: string): Promise // 批量删除 (按前缀) async invalidate(prefix: string): Promise // 判断是否应该缓存 shouldCache(type: CacheStrategyType, params?: any): boolean // 获取统计信息 getStats(): CacheStats // 获取配置 getConfig(): CacheConfig } ``` ### BookmarkCacheManager 书签缓存管理器。 ```typescript class BookmarkCacheManager { // 获取书签列表缓存 async getBookmarkList(userId: string, params?: QueryParams): Promise // 设置书签列表缓存 async setBookmarkList(userId: string, params: QueryParams | undefined, data: T, options?: { async?: boolean }): Promise // 失效用户的所有书签缓存 async invalidateUserBookmarks(userId: string): Promise // 失效特定查询的缓存 async invalidateQuery(userId: string, params?: QueryParams): Promise // 批量操作后的缓存处理 async handleBatchOperation(userId: string): Promise } ``` ### 工具函数 ```typescript // 生成缓存键 generateCacheKey(type: CacheStrategyType, userId: string, params?: QueryParams): string // 判断查询类型 getQueryType(params?: QueryParams): CacheStrategyType // 判断是否应该缓存 shouldCacheQuery(type: CacheStrategyType, params?: QueryParams): boolean // 获取失效前缀 getCacheInvalidationPrefix(userId: string, type?: CacheStrategyType): string ``` ## 💡 最佳实践 ### 1. 使用异步写入 ```typescript // ✅ 推荐:异步写入,不阻塞主流程 await cache.set('defaultList', key, data, { async: true }) // ❌ 避免:同步写入,阻塞响应 await cache.set('defaultList', key, data) ``` ### 2. 批量操作不写缓存 ```typescript // ✅ 推荐:批量导入后只失效缓存 await bookmarkCache.handleBatchOperation(userId) // ❌ 避免:批量导入时逐个写缓存 for (const bookmark of bookmarks) { await cache.set(...) // 不要这样做 } ``` ### 3. 使用缓存管理器 ```typescript // ✅ 推荐:使用封装好的管理器 const bookmarkCache = createBookmarkCacheManager(cache) await bookmarkCache.getBookmarkList(userId, params) // ❌ 避免:直接操作缓存服务 await cache.get('defaultList', `bookmarks:${userId}:...`) ``` ### 4. 检查缓存命中率 ```typescript const stats = cache.getStats() console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(2)}%`) // 如果命中率 < 60%,考虑调整策略 ``` ## 🛡️ 容错机制 ### 1. 自动降级 ```typescript // KV 不可用时自动降级到 D1 const cached = await cache.get('defaultList', key) // 如果 KV 失败,返回 null,触发 D1 查询 ``` ### 2. 超时保护 ```typescript // 100ms 超时,避免缓存拖慢响应 private readonly CACHE_TIMEOUT = 100 ``` ### 3. 错误计数 ```typescript // 错误过多时自动禁用缓存 private readonly MAX_ERRORS = 10 ``` ## 📊 监控 ### 获取统计信息 ```typescript const stats = cache.getStats() console.log({ level: stats.level, // 缓存级别 enabled: stats.enabled, // 是否启用 hits: stats.hits, // 命中次数 misses: stats.misses, // 未命中次数 hitRate: stats.hitRate, // 命中率 memCacheSize: stats.memCacheSize, // 内存缓存大小 }) ``` ### 调试模式 ```toml # wrangler.toml [vars] CACHE_DEBUG = "true" ``` ## 🔄 迁移 参见 [迁移指南](../../../docs/cache-migration-guide.md) ## 📚 相关文档 - [强健缓存策略](../../../docs/robust-cache-strategy.md) - [KV 优化分析](../../../docs/kv-optimization-analysis.md) - [存储架构分析](../../../docs/storage-cache-cloudflare-analysis.md) ## 🤝 贡献 欢迎提交 Issue 和 Pull Request! ## 📄 许可 MIT License ================================================ FILE: tmarks/functions/lib/cache/bookmark-cache.ts ================================================ /** * * */ import { CacheService } from './service' import { generateCacheKey, getQueryType, getCacheInvalidationPrefix } from './strategies' import type { QueryParams } from './types' /** */ export class BookmarkCacheManager { constructor(private cache: CacheService) {} /** * */ async getBookmarkList(userId: string, params?: QueryParams): Promise { const queryType = getQueryType(params) if (!this.cache.shouldCache(queryType, params)) { return null } const cacheKey = generateCacheKey(queryType, userId, params) return await this.cache.get(queryType, cacheKey) } /** * */ async setBookmarkList( userId: string, params: QueryParams | undefined, data: T, options?: { async?: boolean } ): Promise { const queryType = getQueryType(params) if (!this.cache.shouldCache(queryType, params)) { return } const cacheKey = generateCacheKey(queryType, userId, params) await this.cache.set(queryType, cacheKey, data, options) } /** */ async invalidateUserBookmarks(userId: string): Promise { const prefix = getCacheInvalidationPrefix(userId) await this.cache.invalidate(prefix) } /** */ async invalidateQuery(userId: string, params?: QueryParams): Promise { const queryType = getQueryType(params) const cacheKey = generateCacheKey(queryType, userId, params) await this.cache.delete(cacheKey) } /** * */ async handleBatchOperation(userId: string): Promise { const config = this.cache.getConfig() if (config.batchOperations.writeCache && config.batchOperations.asyncWrite) { // Level 3: this.refreshCommonQueries(userId) } else { await this.invalidateUserBookmarks(userId) } } /** * () */ private refreshCommonQueries(userId: string): void { // , Promise.resolve().then(async () => { try { await this.invalidateUserBookmarks(userId) // : } catch (error) { console.warn('Refresh common queries error:', error) } }) } } /** */ export function createBookmarkCacheManager(cache: CacheService): BookmarkCacheManager { return new BookmarkCacheManager(cache) } ================================================ FILE: tmarks/functions/lib/cache/config.ts ================================================ /** * * */ import type { CacheConfig, CacheLevel } from './types' import type { Env } from '../types' /** * */ export const CACHE_PRESETS: Record = { /** * Level 0: () * - : * - : 30-100ms * - : , */ 0: { level: 0, enabled: true, strategies: { rateLimit: false, // KV () publicShare: true, // () defaultList: true, // () tagFilter: false, // search: false, // complexQuery: false, }, ttl: { rateLimit: 0, publicShare: 1800, // 30 () defaultList: 1800, // 30 () tagFilter: 0, search: 0, complexQuery: 0, }, memoryCache: { enabled: true, // () maxAge: 60, // 1 }, batchOperations: { writeCache: false, // () asyncWrite: false, }, }, /** * Level 1: KV */ 1: { level: 1, enabled: true, strategies: { rateLimit: false, // KV () publicShare: true, // () defaultList: false, // tagFilter: false, search: false, complexQuery: false, }, ttl: { rateLimit: 0, publicShare: 1800, // 30 () defaultList: 0, tagFilter: 0, search: 0, complexQuery: 0, }, memoryCache: { enabled: true, maxAge: 60, }, batchOperations: { writeCache: false, asyncWrite: false, }, }, /** * Level 2: KV () * - : (1000-10000 ) */ 2: { level: 2, enabled: true, strategies: { rateLimit: true, publicShare: true, defaultList: true, tagFilter: false, // search: false, complexQuery: false, }, ttl: { rateLimit: 60, publicShare: 1800, // 30 defaultList: 600, // 10 tagFilter: 0, search: 0, complexQuery: 0, }, memoryCache: { enabled: true, maxAge: 60, // 1 }, batchOperations: { writeCache: false, asyncWrite: false, }, }, /** * - : (>10000 ) * - : KV, */ 3: { level: 3, enabled: true, strategies: { rateLimit: true, publicShare: true, defaultList: true, tagFilter: true, // search: false, // complexQuery: false, }, ttl: { rateLimit: 60, publicShare: 1800, // 30 defaultList: 600, // 10 tagFilter: 600, // 10 search: 0, complexQuery: 0, }, memoryCache: { enabled: true, maxAge: 60, }, batchOperations: { writeCache: false, // () asyncWrite: false, }, }, } /** */ export function loadCacheConfig(env: Env): CacheConfig { if (env.ENABLE_KV_CACHE === 'false') { return CACHE_PRESETS[0] } const levelStr = env.CACHE_LEVEL || '1' let level: CacheLevel = 1 if (levelStr === 'none' || levelStr === '0') { level = 0 } else if (levelStr === 'minimal' || levelStr === '1') { level = 1 } else if (levelStr === 'standard' || levelStr === '2') { level = 2 } else if (levelStr === 'aggressive' || levelStr === '3') { level = 3 } else { const parsed = parseInt(levelStr, 10) if (parsed >= 0 && parsed <= 3) { level = parsed as CacheLevel } } const config = { ...CACHE_PRESETS[level] } // TTL if (env.CACHE_TTL_DEFAULT_LIST) { config.ttl.defaultList = parseInt(env.CACHE_TTL_DEFAULT_LIST, 10) } if (env.CACHE_TTL_TAG_FILTER) { config.ttl.tagFilter = parseInt(env.CACHE_TTL_TAG_FILTER, 10) } if (env.CACHE_TTL_SEARCH) { config.ttl.search = parseInt(env.CACHE_TTL_SEARCH, 10) } if (env.CACHE_TTL_PUBLIC_SHARE) { config.ttl.publicShare = parseInt(env.CACHE_TTL_PUBLIC_SHARE, 10) } if (env.ENABLE_MEMORY_CACHE === 'false') { config.memoryCache.enabled = false } if (env.MEMORY_CACHE_MAX_AGE) { config.memoryCache.maxAge = parseInt(env.MEMORY_CACHE_MAX_AGE, 10) } return config } /** * */ export function validateCacheConfig(config: CacheConfig): boolean { if (config.level < 0 || config.level > 3) { return false } for (const ttl of Object.values(config.ttl)) { if (ttl < 0 || ttl > 86400) { // return false } } if (config.memoryCache.maxAge < 0 || config.memoryCache.maxAge > 3600) { return false } return true } ================================================ FILE: tmarks/functions/lib/cache/index.ts ================================================ /** * * */ export type { CacheLevel, CacheStrategyType, CacheConfig, CacheEntry, CacheSetOptions, CacheStats, QueryParams, } from './types' export { CACHE_PRESETS, loadCacheConfig, validateCacheConfig, } from './config' export { generateCacheKey, getQueryType, shouldCacheQuery, getCacheInvalidationPrefix, hashQueryParams, } from './strategies' export { CacheService } from './service' ================================================ FILE: tmarks/functions/lib/cache/service.ts ================================================ /** * * */ import type { Env } from '../types' import type { CacheConfig, CacheStrategyType, CacheEntry, CacheSetOptions, CacheStats, } from './types' import { loadCacheConfig } from './config' import { shouldCacheQuery } from './strategies' /** */ export class CacheService { private config: CacheConfig private env: Env private memCache: Map = new Map() private hits = 0 private misses = 0 private errorCount = 0 private readonly MAX_ERRORS = 10 constructor(env: Env) { this.env = env this.config = loadCacheConfig(env) } /** * */ async get( type: CacheStrategyType, key: string ): Promise { if (!this.isEnabled(type)) { return null } try { if (this.config.memoryCache.enabled) { const memCached = this.getFromMemory(key) if (memCached !== null) { this.hits++ return memCached } } this.misses++ return null } catch (error) { this.handleError('get', error) this.misses++ return null } } /** * */ async set( type: CacheStrategyType, key: string, data: T, options?: CacheSetOptions ): Promise { if (!this.isEnabled(type)) { return } try { if (this.config.memoryCache.enabled) { this.setToMemory(key, data, options?.ttl) } } catch (error) { this.handleError('set', error) } } /** * */ async delete(key: string): Promise { try { this.memCache.delete(key) } catch (error) { this.handleError('delete', error) } } /** */ async invalidate(prefix: string): Promise { try { const keysToDelete: string[] = [] this.memCache.forEach((_, key) => { if (key.startsWith(prefix)) { keysToDelete.push(key) } }) keysToDelete.forEach(key => this.memCache.delete(key)) } catch (error) { this.handleError('invalidate', error) } } /** * */ shouldCache(type: CacheStrategyType, params?: Record): boolean { if (!this.isEnabled(type)) { return false } return shouldCacheQuery(type, params) } /** * */ getStats(): CacheStats { const total = this.hits + this.misses return { level: this.config.level, enabled: this.config.enabled, hits: this.hits, misses: this.misses, hitRate: total > 0 ? this.hits / total : 0, memCacheSize: this.memCache.size, strategies: this.config.strategies, } } /** * */ getConfig(): CacheConfig { return { ...this.config } } // ==================== ==================== /** * */ private isEnabled(type: CacheStrategyType): boolean { return this.config.enabled && this.config.strategies[type] } /** */ private getFromMemory(key: string): T | null { const entry = this.memCache.get(key) if (entry && entry.expires > Date.now()) { return entry.data as T } if (entry) { this.memCache.delete(key) } return null } /** * */ private setToMemory(key: string, data: T, ttlSeconds?: number): void { if (this.memCache.size > 500) { const now = Date.now() for (const [k, entry] of this.memCache.entries()) { if (entry.expires <= now) { this.memCache.delete(k) } } } const maxAge = (ttlSeconds ?? this.config.memoryCache.maxAge) * 1000 this.memCache.set(key, { data, expires: Date.now() + maxAge, }) } /** * */ private handleError(operation: string, error: unknown): void { this.errorCount++ if (this.errorCount >= this.MAX_ERRORS) { console.error(`Too many cache errors (${this.errorCount}), disabling cache`) this.config.enabled = false } const message = error instanceof Error ? error.message : String(error) console.warn(`Cache ${operation} error:`, message) } } ================================================ FILE: tmarks/functions/lib/cache/strategies.ts ================================================ /** * * * */ import type { CacheStrategyType, QueryParams } from './types' /** */ export function generateCacheKey( type: CacheStrategyType, userId: string, params?: QueryParams | Record ): string { const parts: string[] = [] switch (type) { case 'rateLimit': parts.push('ratelimit') break case 'publicShare': parts.push('public-share') break case 'defaultList': case 'tagFilter': case 'search': case 'complexQuery': parts.push('bookmarks') break } // ID if (userId) { parts.push(userId) } if (params) { if (type === 'search' && params.keyword) { parts.push('search', params.keyword) } if (type === 'tagFilter' && params.tags) { const tags = Array.isArray(params.tags) ? params.tags : [params.tags] parts.push('tags', tags.sort().join(',')) } if (params.archived) { parts.push('archived') } if (params.pinned) { parts.push('pinned') } if (params.sort) { parts.push('sort', params.sort) } if (params.page_size) { parts.push('size', String(params.page_size)) } if (params.page_cursor) { parts.push('cursor', String(params.page_cursor)) } } return parts.join(':') } /** * */ export function getQueryType(params?: QueryParams): CacheStrategyType { if (!params) { return 'defaultList' } if (params.keyword) { return 'search' } if (params.tags && params.tags.length > 0) { return 'tagFilter' } if (!params.archived && !params.pinned && !params.sort) { return 'defaultList' } return 'complexQuery' } /** */ export function shouldCacheQuery( type: CacheStrategyType, params?: QueryParams ): boolean { if (type === 'rateLimit' || type === 'publicShare') { return true } if (type === 'defaultList') { return true } if (type === 'tagFilter' && params?.tags) { return params.tags.length <= 3 } if (type === 'search' && params?.keyword) { return params.keyword.length <= 50 } return false } /** * */ export function getCacheInvalidationPrefix( userId: string, type?: CacheStrategyType ): string { if (type === 'publicShare') { return 'public-share:' } if (type === 'rateLimit') { return `ratelimit:${userId}:` } return `bookmarks:${userId}:` } /** */ export function hashQueryParams(params: QueryParams): string { const sorted = Object.keys(params) .sort() .map(key => { const value = params[key as keyof QueryParams] if (Array.isArray(value)) { return `${key}=${value.sort().join(',')}` } return `${key}=${value}` }) .join('&') return sorted } ================================================ FILE: tmarks/functions/lib/cache/types.ts ================================================ export type CacheLevel = 0 | 1 | 2 | 3 export type CacheStrategyType = | 'rateLimit' | 'publicShare' | 'defaultList' | 'tagFilter' | 'search' | 'complexQuery' export interface CacheConfig { level: CacheLevel enabled: boolean strategies: Record ttl: Record memoryCache: { enabled: boolean maxAge: number } batchOperations: { writeCache: boolean asyncWrite: boolean } } export interface CacheEntry { data: T expires: number } export interface CacheSetOptions { async?: boolean ttl?: number } export interface CacheStats { level: CacheLevel enabled: boolean hits: number misses: number hitRate: number memCacheSize: number strategies: Record } export interface QueryParams { keyword?: string tags?: string[] archived?: boolean pinned?: boolean sort?: string page_size?: number page_cursor?: string } ================================================ FILE: tmarks/functions/lib/config.ts ================================================ /** * */ import type { Env } from './types' /** */ export const DEFAULT_CONFIG = { JWT_ACCESS_TOKEN_EXPIRES_IN: '365d', JWT_REFRESH_TOKEN_EXPIRES_IN: '365d', } as const /** */ export function getJwtAccessTokenExpiresIn(env?: Env): string { return env?.JWT_ACCESS_TOKEN_EXPIRES_IN || DEFAULT_CONFIG.JWT_ACCESS_TOKEN_EXPIRES_IN } /** */ export function getJwtRefreshTokenExpiresIn(env?: Env): string { return env?.JWT_REFRESH_TOKEN_EXPIRES_IN || DEFAULT_CONFIG.JWT_REFRESH_TOKEN_EXPIRES_IN } /** * @param env - Cloudflare * @returns */ export function isRegistrationAllowed(env: Env): boolean { return env.ALLOW_REGISTRATION === 'true' } /** * * @param env - Cloudflare * @returns */ export function getEnvironment(env: Env): 'development' | 'production' { return env.ENVIRONMENT || 'development' } ================================================ FILE: tmarks/functions/lib/crypto.ts ================================================ /** */ const PBKDF2_ITERATIONS = 100000 // OWASP const SALT_LENGTH = 16 const HASH_LENGTH = 32 /** * */ export async function hashPassword(password: string): Promise { const encoder = new TextEncoder() const passwordBuffer = encoder.encode(password) const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) const keyMaterial = await crypto.subtle.importKey( 'raw', passwordBuffer, 'PBKDF2', false, ['deriveBits'] ) // PBKDF2 const hashBuffer = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256', }, keyMaterial, HASH_LENGTH * 8 ) const hash = new Uint8Array(hashBuffer) const result = new Uint8Array(salt.length + hash.length) result.set(salt, 0) result.set(hash, salt.length) return `pbkdf2_sha256:${PBKDF2_ITERATIONS}:${arrayBufferToBase64(result)}` } /** * */ export async function verifyPassword(password: string, hash: string): Promise { try { const parts = hash.split(':') if (parts.length !== 3 || parts[0] !== 'pbkdf2_sha256') { return false } const iterations = parseInt(parts[1], 10) const storedHash = base64ToArrayBuffer(parts[2]) const salt = storedHash.slice(0, SALT_LENGTH) const originalHash = storedHash.slice(SALT_LENGTH) const encoder = new TextEncoder() const passwordBuffer = encoder.encode(password) const keyMaterial = await crypto.subtle.importKey( 'raw', passwordBuffer, 'PBKDF2', false, ['deriveBits'] ) const hashBuffer = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt, iterations, hash: 'SHA-256', }, keyMaterial, HASH_LENGTH * 8 ) const computedHash = new Uint8Array(hashBuffer) return timingSafeEqual(originalHash, computedHash) } catch { return false } } /** * */ export function generateToken(length: number = 32): string { const array = crypto.getRandomValues(new Uint8Array(length)) return arrayBufferToBase64(array) } /** * () */ export async function hashRefreshToken(token: string): Promise { const encoder = new TextEncoder() const data = encoder.encode(token) const hashBuffer = await crypto.subtle.digest('SHA-256', data) return arrayBufferToBase64(new Uint8Array(hashBuffer)) } /** */ export function generateUUID(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)) bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant 10 const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` } /** */ export function generateShortUUID(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)) bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4 bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant 10 const binary = Array.from(bytes, b => String.fromCharCode(b)).join('') const base64 = btoa(binary) return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } /** */ export function generateNanoId(length: number = 21): string { const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-' const randomValues = new Uint8Array(length) crypto.getRandomValues(randomValues) let id = '' for (let i = 0; i < length; i++) { id += alphabet[randomValues[i] % alphabet.length] } return id } /** * () */ function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) { return false } let result = 0 for (let i = 0; i < a.length; i++) { result |= a[i] ^ b[i] } return result === 0 } /** */ function arrayBufferToBase64(buffer: Uint8Array): string { const binary = Array.from(buffer, (byte) => String.fromCharCode(byte)).join('') return btoa(binary) } /** */ function base64ToArrayBuffer(base64: string): Uint8Array { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes } ================================================ FILE: tmarks/functions/lib/data-fetchers.ts ================================================ import type { D1Database } from '@cloudflare/workers-types' import type { Bookmark, BookmarkRow } from '../lib/types' export async function fetchFullBookmarks( db: D1Database, rows: BookmarkRow[], userId: string ): Promise { const bookmarkIds = rows.map(r => r.id) if (bookmarkIds.length === 0) return [] // Fetch all tag relations for these bookmarks const { results: tagRelations } = await db .prepare( `SELECT bt.bookmark_id, t.id, t.name, t.color FROM bookmark_tags bt JOIN tags t ON bt.tag_id = t.id WHERE bt.bookmark_id IN (${bookmarkIds.map(() => '?').join(',')}) AND bt.user_id = ?` ) .bind(...bookmarkIds, userId) .all<{ bookmark_id: string; id: string; name: string; color: string }>() // Assemble final results return rows.map(row => { const tags = tagRelations .filter(tr => tr.bookmark_id === row.id) .map(tr => ({ id: tr.id, name: tr.name, color: tr.color, })) return { id: row.id, user_id: row.user_id, title: row.title, url: row.url, description: row.description, cover_image: row.cover_image, favicon: row.favicon, is_pinned: Boolean(row.is_pinned), is_archived: Boolean(row.is_archived), is_public: Boolean(row.is_public), click_count: row.click_count, last_clicked_at: row.last_clicked_at, has_snapshot: row.has_snapshot, latest_snapshot_at: row.latest_snapshot_at, snapshot_count: row.snapshot_count, created_at: row.created_at, updated_at: row.updated_at, deleted_at: row.deleted_at, tags, } }) } ================================================ FILE: tmarks/functions/lib/error-handler.ts ================================================ /** * */ import { internalError, badRequest, unauthorized, forbidden, notFound, conflict } from './response' export interface ErrorContext { userId?: string endpoint?: string method?: string ip?: string userAgent?: string requestId?: string } export interface ErrorDetails { code: string message: string details?: string field?: string context?: ErrorContext } /** */ export function handleError(error: unknown, context?: ErrorContext): Response { const errorDetails = normalizeError(error, context) logError(errorDetails) switch (errorDetails.code) { case 'VALIDATION_ERROR': case 'INVALID_INPUT': case 'MISSING_FIELD': return badRequest(errorDetails.message, errorDetails.code) case 'UNAUTHORIZED': case 'INVALID_TOKEN': case 'TOKEN_EXPIRED': return unauthorized(errorDetails.message, errorDetails.code) case 'FORBIDDEN': case 'INSUFFICIENT_PERMISSIONS': return forbidden(errorDetails.message, errorDetails.code) case 'NOT_FOUND': case 'RESOURCE_NOT_FOUND': return notFound(errorDetails.message, errorDetails.code) case 'CONFLICT': case 'DUPLICATE_RESOURCE': return conflict(errorDetails.message, errorDetails.code) default: return internalError(errorDetails.message, errorDetails.code) } } /** */ function normalizeError(error: unknown, context?: ErrorContext): ErrorDetails { if (error instanceof Error) { if (error.message.includes('UNIQUE constraint failed')) { return { code: 'DUPLICATE_RESOURCE', message: 'Resource already exists', details: error.message, context } } if (error.message.includes('FOREIGN KEY constraint failed')) { return { code: 'INVALID_REFERENCE', message: 'Referenced resource does not exist', details: error.message, context } } if (error.message.includes('no such column')) { return { code: 'DATABASE_SCHEMA_ERROR', message: 'Database schema mismatch', details: error.message, context } } return { code: 'INTERNAL_ERROR', message: error.message, details: error.stack, context } } if (typeof error === 'string') { return { code: 'INTERNAL_ERROR', message: error, context } } return { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred', details: String(error), context } } /** * */ function logError(errorDetails: ErrorDetails): void { const logData = { timestamp: new Date().toISOString(), code: errorDetails.code, message: errorDetails.message, details: errorDetails.details, context: errorDetails.context } // , console.error('Error occurred:', JSON.stringify(logData, null, 2)) } /** */ export function createErrorContext(request: Request, userId?: string): ErrorContext { return { userId, endpoint: new URL(request.url).pathname, method: request.method, ip: request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || 'unknown', userAgent: request.headers.get('User-Agent') || 'unknown', requestId: request.headers.get('X-Request-ID') || crypto.randomUUID() } } /** */ export function withErrorHandling( fn: (...args: T) => Promise, context?: ErrorContext ) { return async (...args: T): Promise => { try { return await fn(...args) } catch (error) { return handleError(error, context) } } } ================================================ FILE: tmarks/functions/lib/image-sig.ts ================================================ /** * Generate HMAC signature for snapshot image URLs */ export async function generateImageSig( hash: string, userId: string, bookmarkId: string, secret: string ): Promise { const data = `img:${hash}:${userId}:${bookmarkId}` const encoder = new TextEncoder() const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) const bytes = new Uint8Array(signature) const CHUNK = 8192 const chunks: string[] = [] for (let i = 0; i < bytes.length; i += CHUNK) { chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK))) } return btoa(chunks.join('')) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, '') } ================================================ FILE: tmarks/functions/lib/image-upload.ts ================================================ /** */ import type { R2Bucket, D1Database } from '@cloudflare/workers-types' import type { Env } from './types' import { generateUUID } from './crypto' import { checkR2Quota } from './storage-quota' interface UploadImageResult { success: boolean imageId?: string r2Url?: string originalUrl: string imageHash?: string fileSize?: number mimeType?: string isReused?: boolean // error?: string } interface ExistingImage { id: string r2_key: string file_size: number mime_type: string } /** * @param imageUrl URL * @param userId ID * @param bookmarkId ID * @param bucket R2 Bucket * @param db D1 Database * @param env Cloudflare () * @returns */ export async function uploadCoverImageToR2( imageUrl: string, userId: string, bookmarkId: string, bucket: R2Bucket, db: D1Database, r2PublicUrl: string, env: Env ): Promise { try { // 1. const response = await fetch(imageUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', }, signal: AbortSignal.timeout(10000), // 10 }) if (!response.ok) { return { success: false, originalUrl: imageUrl, error: `Failed to download image: ${response.status}`, } } // 2. const imageData = await response.arrayBuffer() const contentType = response.headers.get('content-type') || 'image/jpeg' const fileSize = imageData.byteLength if (fileSize > 10 * 1024 * 1024) { return { success: false, originalUrl: imageUrl, error: 'Image too large (max 10MB)', } } if (!contentType.startsWith('image/')) { return { success: false, originalUrl: imageUrl, error: 'Not a valid image', } } const imageHash = await calculateHash(imageData) const existing = await db .prepare('SELECT id, r2_key, file_size, mime_type FROM bookmark_images WHERE image_hash = ? LIMIT 1') .bind(imageHash) .first() let imageId: string let r2Key: string if (existing) { imageId = existing.id r2Key = existing.r2_key const r2Url = `${r2PublicUrl.replace(/\/$/, '')}/${r2Key}` return { success: true, imageId: imageId, r2Url: r2Url, originalUrl: imageUrl, imageHash: imageHash, fileSize: existing.file_size, mimeType: existing.mime_type, isReused: true, } } // 7. R2 key(,) const ext = getExtensionFromContentType(contentType) r2Key = `images/${imageHash}${ext}` // 8. () const quota = await checkR2Quota(db, env, fileSize) if (!quota.allowed) { const usedGB = quota.usedBytes / (1024 * 1024 * 1024) const limitGB = quota.limitBytes / (1024 * 1024 * 1024) return { success: false, originalUrl: imageUrl, error: `Image storage limit exceeded: used ${usedGB.toFixed(2)}GB / ${limitGB.toFixed(2)}GB`, } } await bucket.put(r2Key, imageData, { httpMetadata: { contentType: contentType, }, customMetadata: { imageHash: imageHash, originalUrl: imageUrl, uploadedAt: new Date().toISOString(), }, }) // 10. imageId = generateUUID() const now = new Date().toISOString() await db .prepare( `INSERT INTO bookmark_images (id, bookmark_id, user_id, image_hash, r2_key, r2_bucket, file_size, mime_type, original_url, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'tmarks-snapshots', ?, ?, ?, ?, ?)` ) .bind(imageId, bookmarkId, userId, imageHash, r2Key, fileSize, contentType, imageUrl, now, now) .run() const r2Url = `${r2PublicUrl.replace(/\/$/, '')}/${r2Key}` return { success: true, imageId: imageId, r2Url: r2Url, originalUrl: imageUrl, imageHash: imageHash, fileSize: fileSize, mimeType: contentType, isReused: false, } } catch (error) { return { success: false, originalUrl: imageUrl, error: error instanceof Error ? error.message : 'Unknown error', } } } /** */ async function calculateHash(data: ArrayBuffer): Promise { const hashBuffer = await crypto.subtle.digest('SHA-256', data) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') return hashHex } /** */ function getExtensionFromContentType(contentType: string): string { const typeMap: Record = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg', } return typeMap[contentType.toLowerCase()] || '.jpg' } /** */ export async function deleteBookmarkImage( bookmarkId: string, db: D1Database, bucket: R2Bucket ): Promise { try { // 1. const image = await db .prepare('SELECT id, r2_key, image_hash FROM bookmark_images WHERE bookmark_id = ?') .bind(bookmarkId) .first<{ id: string; r2_key: string; image_hash: string }>() if (!image) { return } const { count } = await db .prepare('SELECT COUNT(*) as count FROM bookmark_images WHERE image_hash = ?') .bind(image.image_hash) .first<{ count: number }>() || { count: 0 } await db.prepare('DELETE FROM bookmark_images WHERE id = ?').bind(image.id).run() if (count <= 1) { await bucket.delete(image.r2_key) } } catch (error) { console.error('Failed to delete bookmark image:', error) } } /** * () */ export async function cleanupOrphanedImages(db: D1Database, bucket: R2Bucket): Promise { try { const { results: orphaned } = await db .prepare( `SELECT bi.id, bi.r2_key, bi.image_hash FROM bookmark_images bi LEFT JOIN bookmarks b ON bi.bookmark_id = b.id WHERE b.id IS NULL` ) .all<{ id: string; r2_key: string; image_hash: string }>() if (!orphaned || orphaned.length === 0) { return 0 } let deletedCount = 0 for (const image of orphaned) { const { count } = await db .prepare('SELECT COUNT(*) as count FROM bookmark_images WHERE image_hash = ?') .bind(image.image_hash) .first<{ count: number }>() || { count: 0 } await db.prepare('DELETE FROM bookmark_images WHERE id = ?').bind(image.id).run() if (count <= 1) { await bucket.delete(image.r2_key) } deletedCount++ } return deletedCount } catch (error) { console.error('Failed to cleanup orphaned images:', error) return 0 } } ================================================ FILE: tmarks/functions/lib/import-export/collect-export-data.ts ================================================ import type { TMarksExportData, ExportBookmark, ExportTag, ExportUser, ExportTabGroup, ExportTabGroupItem, } from '../../../shared/import-export-types' import { EXPORT_VERSION } from '../../../shared/import-export-types' import type { ExportScope } from './export-scope' const DEFAULT_TAG_COLOR = '#3b82f6' function parseMaybeJsonStringArray(raw: unknown): string[] | undefined { if (raw == null) return undefined if (Array.isArray(raw)) return raw.map(String) if (typeof raw !== 'string') return undefined try { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) return parsed.map(String) return undefined } catch { return undefined } } async function collectUser(db: D1Database, userId: string): Promise { interface UserRow { id: string email: string | null username: string created_at: string } if (userId === 'default-user') { return { id: 'default-user', email: 'default@tmarks.local', name: 'Default User', created_at: new Date().toISOString(), } } const { results: users } = await db .prepare('SELECT id, email, username, created_at FROM users WHERE id = ?') .bind(userId) .all() const foundUser = users?.[0] if (!foundUser) throw new Error('User not found') return { id: foundUser.id, email: foundUser.email ?? '', name: foundUser.username, created_at: foundUser.created_at, } } async function collectBookmarksAndTags( db: D1Database, userId: string, includeDeleted: boolean ): Promise<{ bookmarks: ExportBookmark[]; tags: ExportTag[] }> { const bookmarkWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL' const tagWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL' const { results: bookmarks } = await db .prepare( ` SELECT id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, click_count, last_clicked_at, has_snapshot, latest_snapshot_at, snapshot_count, created_at, updated_at, deleted_at FROM bookmarks WHERE ${bookmarkWhere} ORDER BY created_at DESC ` ) .bind(userId) .all() const { results: tags } = await db .prepare( ` SELECT id, name, color, click_count, last_clicked_at, created_at, updated_at, deleted_at FROM tags WHERE ${tagWhere} ORDER BY name ASC ` ) .bind(userId) .all() const bookmarkTagSql = includeDeleted ? ` SELECT bt.bookmark_id, bt.tag_id, t.name as tag_name FROM bookmark_tags bt JOIN tags t ON bt.tag_id = t.id WHERE bt.user_id = ? ` : ` SELECT bt.bookmark_id, bt.tag_id, t.name as tag_name FROM bookmark_tags bt JOIN tags t ON bt.tag_id = t.id JOIN bookmarks b ON bt.bookmark_id = b.id WHERE bt.user_id = ? AND t.deleted_at IS NULL AND b.deleted_at IS NULL ` const { results: bookmarkTags } = await db.prepare(bookmarkTagSql).bind(userId).all() const bookmarkTagMap = new Map() const tagCountMap = new Map() bookmarkTags?.forEach((bt: Record) => { const bookmarkId = String(bt.bookmark_id) const tagName = String(bt.tag_name) const list = bookmarkTagMap.get(bookmarkId) ?? [] list.push(tagName) bookmarkTagMap.set(bookmarkId, list) }) // Compute bookmark_count per tag name for (const tagList of bookmarkTagMap.values()) { for (const tagName of tagList) { tagCountMap.set(tagName, (tagCountMap.get(tagName) ?? 0) + 1) } } const exportBookmarks: ExportBookmark[] = (bookmarks || []).map((bookmark: Record) => ({ id: String(bookmark.id), title: String(bookmark.title), url: String(bookmark.url), description: (bookmark.description ?? null) as string | null, cover_image: (bookmark.cover_image ?? null) as string | null, cover_image_id: (bookmark.cover_image_id ?? null) as string | null, favicon: (bookmark.favicon ?? null) as string | null, tags: bookmarkTagMap.get(String(bookmark.id)) || [], is_pinned: Boolean(bookmark.is_pinned), is_archived: Boolean(bookmark.is_archived), is_public: Boolean(bookmark.is_public), click_count: Number(bookmark.click_count ?? 0), last_clicked_at: (bookmark.last_clicked_at ?? null) as string | null, has_snapshot: Boolean(bookmark.has_snapshot), latest_snapshot_at: (bookmark.latest_snapshot_at ?? null) as string | null, snapshot_count: Number(bookmark.snapshot_count ?? 0), created_at: String(bookmark.created_at), updated_at: String(bookmark.updated_at), deleted_at: (bookmark.deleted_at ?? null) as string | null, })) const exportTags: ExportTag[] = (tags || []).map((tag: Record) => ({ id: String(tag.id), name: String(tag.name), color: (tag.color == null || tag.color === '') ? DEFAULT_TAG_COLOR : String(tag.color), click_count: Number(tag.click_count ?? 0), last_clicked_at: (tag.last_clicked_at ?? null) as string | null, created_at: String(tag.created_at), updated_at: String(tag.updated_at), deleted_at: (tag.deleted_at ?? null) as string | null, bookmark_count: tagCountMap.get(String(tag.name)) ?? 0, })) return { bookmarks: exportBookmarks, tags: exportTags } } async function collectTabGroups( db: D1Database, userId: string, includeDeleted: boolean ): Promise { const where = includeDeleted ? 'user_id = ?' : 'user_id = ? AND is_deleted = 0' const { results: tabGroups } = await db .prepare( ` SELECT id, title, parent_id, is_folder, position, color, tags, is_deleted, deleted_at, created_at, updated_at FROM tab_groups WHERE ${where} ORDER BY position ASC ` ) .bind(userId) .all() const { results: tabGroupItems } = await db .prepare( ` SELECT tgi.id, tgi.group_id, tgi.title, tgi.url, tgi.favicon, tgi.position, tgi.is_pinned, tgi.is_todo, tgi.is_archived, tgi.created_at FROM tab_group_items tgi JOIN tab_groups tg ON tgi.group_id = tg.id WHERE tg.user_id = ? ${includeDeleted ? '' : 'AND tg.is_deleted = 0'} ORDER BY tgi.position ASC ` ) .bind(userId) .all() const groupItemsMap = new Map() tabGroupItems?.forEach((item: Record) => { const groupId = String(item.group_id) const list = groupItemsMap.get(groupId) ?? [] list.push({ id: String(item.id), title: String(item.title), url: String(item.url), favicon: item.favicon ? String(item.favicon) : undefined, position: Number(item.position), is_pinned: Boolean(item.is_pinned), is_todo: Boolean(item.is_todo), is_archived: Boolean(item.is_archived), created_at: String(item.created_at), }) groupItemsMap.set(groupId, list) }) return (tabGroups || []).map((group: Record) => ({ id: String(group.id), title: String(group.title), parent_id: group.parent_id ? String(group.parent_id) : undefined, is_folder: Boolean(group.is_folder), position: Number(group.position), color: group.color ? String(group.color) : undefined, tags: parseMaybeJsonStringArray(group.tags), is_deleted: Boolean(group.is_deleted), deleted_at: group.deleted_at ? String(group.deleted_at) : undefined, created_at: String(group.created_at), updated_at: String(group.updated_at), items: groupItemsMap.get(String(group.id)) || [], })) } export async function collectExportData( db: D1Database, userId: string, scope: ExportScope, includeDeleted: boolean ): Promise { const exportedAt = new Date().toISOString() const user = await collectUser(db, userId) const shouldBookmarks = scope === 'all' || scope === 'bookmarks' const shouldTabGroups = scope === 'all' || scope === 'tab_groups' const [{ bookmarks, tags }, tab_groups] = await Promise.all([ shouldBookmarks ? collectBookmarksAndTags(db, userId, includeDeleted) : Promise.resolve({ bookmarks: [], tags: [] }), shouldTabGroups ? collectTabGroups(db, userId, includeDeleted) : Promise.resolve([] as ExportTabGroup[]), ]) return { version: EXPORT_VERSION, format: 'tmarks' as const, exported_at: exportedAt, user, bookmarks, tags, tab_groups, metadata: { total_bookmarks: bookmarks.length, total_tags: tags.length, total_tab_groups: tab_groups.length, export_format: 'json', source: 'tmarks', }, } } ================================================ FILE: tmarks/functions/lib/import-export/export-scope.ts ================================================ export type ExportScope = 'all' | 'bookmarks' | 'tab_groups' export function parseExportScope(raw: string | null | undefined): ExportScope { if (raw === 'bookmarks' || raw === 'tab_groups' || raw === 'all') return raw return 'all' } export function getExportFilename(exportedAtIso: string, scope: ExportScope): string { const date = new Date(exportedAtIso) const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-') // HH-MM-SS const prefix = scope === 'bookmarks' ? 'tmarks-bookmarks-export' : scope === 'tab_groups' ? 'tmarks-tab-groups-export' : 'tmarks-export' return `${prefix}-${dateStr}-${timeStr}.json` } ================================================ FILE: tmarks/functions/lib/import-export/export-stats.ts ================================================ import type { ExportScope } from './export-scope' export interface ExportStats { total_bookmarks: number total_tags: number pinned_bookmarks: number total_tab_groups: number } export async function getExportStats( db: D1Database, userId: string, scope: ExportScope, includeDeleted: boolean ): Promise { interface CountRow { count: number } const bookmarkWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL' const tagWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL' const tabGroupWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND is_deleted = 0' const shouldBookmarks = scope === 'all' || scope === 'bookmarks' const shouldTabGroups = scope === 'all' || scope === 'tab_groups' const [bookmarkCount, tagCount, pinnedCount, tabGroupCount] = await Promise.all([ shouldBookmarks ? db.prepare(`SELECT COUNT(*) as count FROM bookmarks WHERE ${bookmarkWhere}`).bind(userId).first() : Promise.resolve({ count: 0 } as CountRow), shouldBookmarks ? db.prepare(`SELECT COUNT(*) as count FROM tags WHERE ${tagWhere}`).bind(userId).first() : Promise.resolve({ count: 0 } as CountRow), shouldBookmarks ? db.prepare( `SELECT COUNT(*) as count FROM bookmarks WHERE ${bookmarkWhere} AND is_pinned = 1` ).bind(userId).first() : Promise.resolve({ count: 0 } as CountRow), shouldTabGroups ? db.prepare(`SELECT COUNT(*) as count FROM tab_groups WHERE ${tabGroupWhere}`).bind(userId).first() : Promise.resolve({ count: 0 } as CountRow), ]) return { total_bookmarks: bookmarkCount?.count || 0, total_tags: tagCount?.count || 0, pinned_bookmarks: pinnedCount?.count || 0, total_tab_groups: tabGroupCount?.count || 0, } } export function estimateExportSize(stats: ExportStats): number { // Heuristic. Export is JSON; real size depends on text lengths and tab-group items. const avgBookmarkSize = 350 const avgTagSize = 90 const avgTabGroupSize = 700 return ( stats.total_bookmarks * avgBookmarkSize + stats.total_tags * avgTagSize + stats.total_tab_groups * avgTabGroupSize ) } ================================================ FILE: tmarks/functions/lib/import-export/exporters/html-exporter.ts ================================================ /** * Netscape , */ import type { Exporter, TMarksExportData, ExportOptions, ExportOutput } from '../../../../shared/import-export-types' import { generateTabGroupsNetscapeSection } from './tab-groups-netscape' export class HtmlExporter implements Exporter { readonly format = 'html' as const async export(data: TMarksExportData, options?: ExportOptions): Promise { try { // HTML const htmlContent = this.generateHtml(data, options) const filename = this.generateFilename(data.exported_at) return { content: htmlContent, filename, mimeType: 'text/html', size: new TextEncoder().encode(htmlContent).length } } catch (error) { throw new Error(`HTML export failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } } private generateHtml(data: TMarksExportData, options?: ExportOptions): string { const includeMetadata = options?.include_metadata ?? true const includeTags = options?.include_tags ?? true const bookmarksByFolder = this.organizeBookmarksByFolder( data.bookmarks as Array>, includeTags ) const tabGroupsSection = generateTabGroupsNetscapeSection({ tabGroups: data.tab_groups, exportedAt: data.exported_at, escapeHtml: (text) => this.escapeHtml(text), toUnixTimestamp: (iso) => this.toUnixTimestamp(iso), }) const html = ` Bookmarks

Bookmarks

${tabGroupsSection} ${this.generateBookmarkFolders(bookmarksByFolder, data.exported_at)} ${includeMetadata ? this.generateMetadataComment(data) : ''}

` return html } private organizeBookmarksByFolder(bookmarks: Array>, includeTags: boolean): Map>> { const folderMap = new Map>>() folderMap.set('Uncategorized', []) bookmarks.forEach(bookmark => { const tags = bookmark.tags as string[] | undefined if (!includeTags || !tags || tags.length === 0) { // const uncategorized = folderMap.get('Uncategorized') if (uncategorized) { uncategorized.push(bookmark) } } else { tags.forEach((tag: string) => { if (!folderMap.has(tag)) { folderMap.set(tag, []) } const folder = folderMap.get(tag) if (folder) { folder.push(bookmark) } }) } }) // const uncategorized = folderMap.get('Uncategorized') if (uncategorized && uncategorized.length === 0) { folderMap.delete('Uncategorized') } return folderMap } private generateBookmarkFolders(folderMap: Map>>, exportedAt: string): string { let html = '' folderMap.forEach((bookmarks, folderName) => { if (bookmarks.length === 0) return html += `

${this.escapeHtml(folderName)}

\n` html += `

\n` bookmarks.forEach(bookmark => { html += this.generateBookmarkEntry(bookmark) }) html += `

\n` }) return html } private generateBookmarkEntry(bookmark: Record): string { const addDate = bookmark.created_at ? this.toUnixTimestamp(bookmark.created_at) : this.toUnixTimestamp(new Date().toISOString()) const lastModified = bookmark.updated_at ? this.toUnixTimestamp(bookmark.updated_at) : addDate const attributes = [ `HREF="${this.escapeHtml(bookmark.url)}"`, `ADD_DATE="${addDate}"`, `LAST_MODIFIED="${lastModified}"` ] if (bookmark.is_pinned) { attributes.push('PERSONAL_TOOLBAR_FOLDER="true"') } if (bookmark.tags && bookmark.tags.length > 0) { attributes.push(`TAGS="${this.escapeHtml(bookmark.tags.join(','))}"`) } // let entry = `

${this.escapeHtml(bookmark.title)}\n` // if (bookmark.description) { entry += `
${this.escapeHtml(bookmark.description)}\n` } return entry } private generateMetadataComment(data: TMarksExportData): string { const stats = { totalBookmarks: data.bookmarks.length, totalTags: data.tags.length, totalTabGroups: data.tab_groups?.length || 0, totalTabGroupItems: data.tab_groups?.reduce((sum, g) => sum + (g.items?.length || 0), 0) || 0, exportedAt: data.exported_at, version: data.version } return ` ` } private generateFilename(exportedAt: string): string { const date = new Date(exportedAt) const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD return `tmarks-bookmarks-${dateStr}.html` } private toUnixTimestamp(isoString: string): string { return Math.floor(new Date(isoString).getTime() / 1000).toString() } private escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * HTML */ validateData(data: TMarksExportData): { valid: boolean; errors: string[] } { const errors: string[] = [] // if (!Array.isArray(data.bookmarks)) { errors.push('Bookmarks must be an array') } // data.bookmarks.forEach((bookmark, index) => { if (!bookmark.title) { errors.push(`Bookmark ${index}: missing title`) } if (!bookmark.url) { errors.push(`Bookmark ${index}: missing url`) } if (!this.isValidUrl(bookmark.url)) { errors.push(`Bookmark ${index}: invalid URL format`) } }) return { valid: errors.length === 0, errors } } private isValidUrl(url: string): boolean { try { new URL(url) return true } catch { return false } } /** * HTML */ getPreview(data: TMarksExportData, maxItems: number = 5): string { const previewData = { ...data, bookmarks: data.bookmarks.slice(0, maxItems) } return this.generateHtml(previewData, { include_metadata: false, include_tags: true, format_options: {} }) } } /** */ export function createHtmlExporter(): HtmlExporter { return new HtmlExporter() } /** */ export async function exportToHtml( data: TMarksExportData, options?: ExportOptions ): Promise { const exporter = createHtmlExporter() const result = await exporter.export(data, options) return result.content as string } ================================================ FILE: tmarks/functions/lib/import-export/exporters/json-exporter.ts ================================================ /** * TMarks JSON */ import type { Exporter, TMarksExportData, ExportOptions, ExportOutput } from '../../../../shared/import-export-types' export class JsonExporter implements Exporter { readonly format = 'json' as const async export(data: TMarksExportData, options?: ExportOptions): Promise { try { // const filteredData = this.filterData(data, options) const jsonContent = this.formatJson(filteredData, options) const filename = this.generateFilename(data.exported_at) return { content: jsonContent, filename, mimeType: 'application/json', size: new TextEncoder().encode(jsonContent).length } } catch (error) { throw new Error(`JSON export failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } } private filterData(data: TMarksExportData, options?: ExportOptions): TMarksExportData { const filtered = { ...data } // if (!options?.include_tags) { filtered.tags = [] filtered.bookmarks = filtered.bookmarks.map(bookmark => ({ ...bookmark, tags: [] })) } if (!options?.include_metadata) { delete filtered.metadata } // if (!options?.format_options?.include_user_info) { filtered.user = { id: filtered.user.id, email: '', created_at: filtered.user.created_at } } // if (!options?.format_options?.include_click_stats) { filtered.bookmarks = filtered.bookmarks.map(bookmark => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { click_count, last_clicked_at, ...rest } = bookmark return rest }) } return filtered } private formatJson(data: TMarksExportData, options?: ExportOptions): string { const prettyPrint = options?.format_options?.pretty_print ?? true if (prettyPrint) { return JSON.stringify(data, null, 2) } else { return JSON.stringify(data) } } private generateFilename(exportedAt: string): string { const date = new Date(exportedAt) const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-') // HH-MM-SS return `tmarks-export-${dateStr}-${timeStr}.json` } /** */ validateData(data: TMarksExportData): { valid: boolean; errors: string[] } { const errors: string[] = [] // if (!data.version) errors.push('Missing version field') if (!data.exported_at) errors.push('Missing exported_at field') if (!data.user?.id) errors.push('Missing user.id field') if (!Array.isArray(data.bookmarks)) errors.push('Bookmarks must be an array') if (!Array.isArray(data.tags)) errors.push('Tags must be an array') data.bookmarks.forEach((bookmark, index) => { if (!bookmark.id) errors.push(`Bookmark ${index}: missing id`) if (!bookmark.title) errors.push(`Bookmark ${index}: missing title`) if (!bookmark.url) errors.push(`Bookmark ${index}: missing url`) if (!this.isValidUrl(bookmark.url)) errors.push(`Bookmark ${index}: invalid URL`) if (!Array.isArray(bookmark.tags)) errors.push(`Bookmark ${index}: tags must be an array`) }) data.tags.forEach((tag, index) => { if (!tag.id) errors.push(`Tag ${index}: missing id`) if (!tag.name) errors.push(`Tag ${index}: missing name`) if (!tag.color) errors.push(`Tag ${index}: missing color`) if (!this.isValidColor(tag.color)) errors.push(`Tag ${index}: invalid color format`) }) return { valid: errors.length === 0, errors } } private isValidUrl(url: string): boolean { try { new URL(url) return true } catch { return false } } private isValidColor(color: string): boolean { return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color) } /** * */ getExportStats(data: TMarksExportData): { totalBookmarks: number totalTags: number pinnedBookmarks: number taggedBookmarks: number estimatedSize: number } { const pinnedBookmarks = data.bookmarks.filter(b => b.is_pinned).length const taggedBookmarks = data.bookmarks.filter(b => b.tags.length > 0).length const estimatedSize = new TextEncoder().encode(JSON.stringify(data)).length return { totalBookmarks: data.bookmarks.length, totalTags: data.tags.length, pinnedBookmarks, taggedBookmarks, estimatedSize } } } /** */ export function createJsonExporter(): JsonExporter { return new JsonExporter() } /** */ export async function exportToJson( data: TMarksExportData, options?: ExportOptions ): Promise { const exporter = createJsonExporter() const result = await exporter.export(data, options) return result.content as string } /** * JSON */ export async function exportToCompactJson(data: TMarksExportData): Promise { const options: ExportOptions = { include_tags: true, include_metadata: true, format_options: { pretty_print: false, include_click_stats: false, include_user_info: false } } return exportToJson(data, options) } ================================================ FILE: tmarks/functions/lib/import-export/exporters/tab-groups-netscape.ts ================================================ import type { ExportTabGroup, ExportTabGroupItem } from '../../../../shared/import-export-types' type EscapeHtml = (text: string) => string type ToUnixTimestamp = (isoString: string) => string export function generateTabGroupsNetscapeSection(params: { tabGroups: ExportTabGroup[] | undefined exportedAt: string escapeHtml: EscapeHtml toUnixTimestamp: ToUnixTimestamp }): string { const { tabGroups, exportedAt, escapeHtml, toUnixTimestamp } = params if (!tabGroups || tabGroups.length === 0) return '' type TabGroupNode = ExportTabGroup & { children: TabGroupNode[] } const nodeById = new Map() for (const group of tabGroups) { nodeById.set(group.id, { ...group, children: [] }) } const roots: TabGroupNode[] = [] for (const node of nodeById.values()) { const parentId = node.parent_id const parent = parentId && parentId !== node.id ? nodeById.get(parentId) : undefined if (parent) parent.children.push(node) else roots.push(node) } const sortTree = (nodes: TabGroupNode[]) => { nodes.sort((a, b) => a.position - b.position) nodes.forEach((n) => sortTree(n.children)) } sortTree(roots) const generateTabGroupItemEntry = (item: ExportTabGroupItem, depth: number): string => { const indent = ' '.repeat(Math.max(depth, 0)) const addDate = item.created_at ? toUnixTimestamp(item.created_at) : toUnixTimestamp(new Date().toISOString()) const tags: string[] = ['tmarks_tab_group'] if (item.is_pinned) tags.push('pinned') if (item.is_todo) tags.push('todo') if (item.is_archived) tags.push('archived') const attributes = [ `HREF="${escapeHtml(item.url)}"`, `ADD_DATE="${addDate}"`, `LAST_MODIFIED="${addDate}"`, `TAGS="${escapeHtml(tags.join(','))}"` ] let entry = `${indent}
${escapeHtml(item.title)}\n` if (item.is_todo || item.is_archived || item.is_pinned) { const flags = [ item.is_pinned ? 'pinned' : null, item.is_todo ? 'todo' : null, item.is_archived ? 'archived' : null ].filter(Boolean).join(', ') entry += `${indent}
Status: ${escapeHtml(flags)}\n` } return entry } const renderNode = (node: TabGroupNode, depth: number, visited: Set): string => { if (visited.has(node.id)) return '' visited.add(node.id) const indent = ' '.repeat(Math.max(depth, 0)) const addDate = toUnixTimestamp(node.created_at || exportedAt) const lastModified = toUnixTimestamp(node.updated_at || node.created_at || exportedAt) let html = '' html += `${indent}

${escapeHtml(node.title)}

\n` html += `${indent}

\n` const items = [...(node.items || [])].sort((a, b) => a.position - b.position) for (const item of items) { html += generateTabGroupItemEntry(item, depth + 1) } for (const child of node.children) { html += renderNode(child, depth + 1, visited) } html += `${indent}

\n` return html } const headerAddDate = toUnixTimestamp(exportedAt) let html = '' html += `

Tab Groups (TMarks)

\n` html += `

\n` const visited = new Set() for (const root of roots) { html += renderNode(root, 2, visited) } html += `

\n` return html } ================================================ FILE: tmarks/functions/lib/index.ts ================================================ // ============ Functions Library Exports ============ export * from './bookmark-utils'; export * from './config'; export * from './crypto'; export * from './error-handler'; export * from './image-upload'; export * from './input-sanitizer'; export * from './jwt'; export * from './rate-limit'; export * from './response'; export * from './signed-url'; export * from './storage-quota'; export * from './tags'; export * from './types'; export * from './utils'; export * from './validation'; ================================================ FILE: tmarks/functions/lib/input-sanitizer.ts ================================================ /** */ /** * HTML */ export function escapeHtml(text: string): string { const map: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', } return text.replace(/[&<>"'/]/g, (s) => map[s]) } /** * */ export function sanitizeString(input: string, options: { maxLength?: number allowHtml?: boolean trimWhitespace?: boolean } = {}): string { const { maxLength = 1000, allowHtml = false, trimWhitespace = true } = options let result = input if (trimWhitespace) { result = result.trim() } if (result.length > maxLength) { result = result.substring(0, maxLength) } if (!allowHtml) { result = escapeHtml(result) } return result } /** */ export function sanitizeUrl(url: string): string | null { try { const parsed = new URL(url.trim()) if (!['http:', 'https:'].includes(parsed.protocol)) { return null } // JavaScript if (parsed.protocol === 'javascript:') { return null } return parsed.toString() } catch { return null } } /** * */ export function sanitizeEmail(email: string): string | null { const trimmed = email.trim().toLowerCase() const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ if (!emailRegex.test(trimmed)) { return null } if (trimmed.length > 254) { return null } return trimmed } /** * */ export function sanitizeUsername(username: string): string | null { const trimmed = username.trim() // :3-20,、、 const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/ if (!usernameRegex.test(trimmed)) { return null } return trimmed } /** * */ export function sanitizeTagName(tagName: string): string | null { const trimmed = tagName.trim() if (trimmed.length === 0 || trimmed.length > 50) { return null } const cleaned = trimmed.replace(/[<>"'&]/g, '') if (cleaned.length === 0) { return null } return cleaned } /** */ export function sanitizeFileName(fileName: string): string { return fileName .replace(/[/\\:*?"<>|]/g, '') .replace(/\.\./g, '') .trim() .substring(0, 255) } /** */ export function sanitizeColor(color: string): string | null { const trimmed = color.trim() // hex const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/ if (hexRegex.test(trimmed)) { return trimmed.toLowerCase() } const allowedColors = [ 'red', 'blue', 'green', 'yellow', 'orange', 'purple', 'pink', 'cyan', 'gray', 'black', 'white' ] if (allowedColors.includes(trimmed.toLowerCase())) { return trimmed.toLowerCase() } return null } /** * */ export function sanitizeSearchQuery(query: string): string { return query .trim() .replace(/[<>"'&]/g, '') .substring(0, 100) } /** * */ export function sanitizePaginationParams(params: { page?: string | number pageSize?: string | number cursor?: string }): { page: number pageSize: number cursor?: string } { const page = Math.max(1, parseInt(String(params.page || 1), 10) || 1) const pageSize = Math.min(100, Math.max(1, parseInt(String(params.pageSize || 30), 10) || 30)) const result: { page: number; pageSize: number; cursor?: string } = { page, pageSize } if (params.cursor && typeof params.cursor === 'string') { if (/^[a-zA-Z0-9-]+$/.test(params.cursor)) { result.cursor = params.cursor } } return result } /** */ export function sanitizeObject>( obj: T, rules: Partial unknown>> ): Partial { const result: Partial = {} for (const [key, value] of Object.entries(obj)) { const rule = rules[key as keyof T] if (rule && value !== undefined && value !== null) { const sanitized = rule(value) if (sanitized !== null && sanitized !== undefined) { result[key as keyof T] = sanitized } } } return result } /** * JSON */ export function validateJsonStructure( data: unknown, schema: Record ): boolean { if (typeof data !== 'object' || data === null) { return false } const obj = data as Record for (const [key, expectedType] of Object.entries(schema)) { const value = obj[key] switch (expectedType) { case 'string': if (typeof value !== 'string') return false break case 'number': if (typeof value !== 'number') return false break case 'boolean': if (typeof value !== 'boolean') return false break case 'array': if (!Array.isArray(value)) return false break case 'object': if (typeof value !== 'object' || value === null) return false break } } return true } ================================================ FILE: tmarks/functions/lib/jwt.ts ================================================ export interface JWTPayload { sub: string // user_id exp: number iat: number session_id?: string } /** * Generate JWT token */ export async function generateJWT( payload: Omit, secret: string, expiresIn: string = '30d' ): Promise { const now = Math.floor(Date.now() / 1000) const exp = now + parseExpiry(expiresIn) const fullPayload: JWTPayload = { ...payload, iat: now, exp, } const header = { alg: 'HS256', typ: 'JWT', } const encodedHeader = base64UrlEncode(JSON.stringify(header)) const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload)) const signature = await sign(`${encodedHeader}.${encodedPayload}`, secret) return `${encodedHeader}.${encodedPayload}.${signature}` } /** * Verify JWT token */ export async function verifyJWT(token: string, secret: string): Promise { const parts = token.split('.') if (parts.length !== 3) { throw new Error('Invalid token format') } const [encodedHeader, encodedPayload, signature] = parts const expectedSignature = await sign(`${encodedHeader}.${encodedPayload}`, secret) if (signature !== expectedSignature) { throw new Error('Invalid signature') } // Decode payload const payload: JWTPayload = JSON.parse(base64UrlDecode(encodedPayload)) // Check expiration const now = Math.floor(Date.now() / 1000) if (payload.exp < now) { throw new Error('Token expired') } return payload } /** * Extract JWT from request */ export function extractJWT(request: Request): string | null { const authHeader = request.headers.get('Authorization') if (!authHeader || !authHeader.startsWith('Bearer ')) { return null } return authHeader.substring(7) } /** * Sign data using Web Crypto API */ async function sign(data: string, secret: string): Promise { const encoder = new TextEncoder() const keyData = encoder.encode(secret) const dataBuffer = encoder.encode(data) const key = await crypto.subtle.importKey( 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const signature = await crypto.subtle.sign('HMAC', key, dataBuffer) return base64UrlEncode(signature) } /** * Base64 URL encode */ function base64UrlEncode(data: string | ArrayBuffer): string { let base64: string if (typeof data === 'string') { base64 = btoa(data) } else { const bytes = new Uint8Array(data) const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') base64 = btoa(binary) } return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } /** * Base64 URL decode */ function base64UrlDecode(data: string): string { let base64 = data.replace(/-/g, '+').replace(/_/g, '/') while (base64.length % 4) { base64 += '=' } return atob(base64) } /** * Parse expiry string (e.g. "15m", "7d") */ export function parseExpiry(expiry: string): number { const match = expiry.match(/^(\d+)([smhd])$/) if (!match) { throw new Error('Invalid expiry format') } const value = parseInt(match[1], 10) const unit = match[2] switch (unit) { case 's': return value case 'm': return value * 60 case 'h': return value * 60 * 60 case 'd': return value * 24 * 60 * 60 default: throw new Error('Invalid expiry unit') } } ================================================ FILE: tmarks/functions/lib/rate-limit.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env } from './types' type RateLimiterContext = Parameters>[0] interface RateLimitResult { allowed: boolean remaining: number resetAt: number retryAfter?: number } let ensureTablePromise: Promise | null = null async function ensureTable(db: D1Database): Promise { if (ensureTablePromise) return ensureTablePromise ensureTablePromise = db .prepare( `CREATE TABLE IF NOT EXISTS rate_limits ( key TEXT NOT NULL, window_seconds INTEGER NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (key, window_seconds, window_start) )` ) .run() .then(() => undefined) .catch(() => undefined) return ensureTablePromise } function getWindowStart(now: number, windowSeconds: number): number { const windowMs = windowSeconds * 1000 return Math.floor(now / windowMs) * windowMs } async function checkAndRecord( db: D1Database, key: string, limit: number, windowSeconds: number ): Promise { const now = Date.now() const windowStart = getWindowStart(now, windowSeconds) const resetAt = windowStart + windowSeconds * 1000 try { await ensureTable(db) // Atomic upsert + increment; count is the *new* count after this request. const row = await db .prepare( `INSERT INTO rate_limits (key, window_seconds, window_start, count, updated_at) VALUES (?, ?, ?, 1, ?) ON CONFLICT(key, window_seconds, window_start) DO UPDATE SET count = count + 1, updated_at = excluded.updated_at RETURNING count` ) .bind(key, windowSeconds, windowStart, now) .first<{ count: number }>() const count = Number(row?.count || 0) const allowed = count <= limit const remaining = Math.max(0, limit - count) return { allowed, remaining, resetAt, retryAfter: allowed ? undefined : Math.max(0, Math.ceil((resetAt - now) / 1000)), } } catch { // Fail-open to avoid accidental outage. return { allowed: true, remaining: limit, resetAt } } } export function getClientIP(request: Request): string { const cfIP = request.headers.get('CF-Connecting-IP') if (cfIP) return cfIP const xForwardedFor = request.headers.get('X-Forwarded-For') if (xForwardedFor) { return xForwardedFor.split(',')[0].trim() } const xRealIP = request.headers.get('X-Real-IP') if (xRealIP) return xRealIP return 'unknown' } export function createRateLimiter( getKey: (context: RateLimiterContext) => string, limit: number, windowSeconds: number ): PagesFunction { return async (context) => { const key = getKey(context) const result = await checkAndRecord(context.env.DB, key, limit, windowSeconds) const headers: Record = { 'X-RateLimit-Limit': String(limit), 'X-RateLimit-Remaining': String(result.remaining), 'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)), } if (!result.allowed) { const retryAfter = result.retryAfter ?? 0 return new Response( JSON.stringify({ code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later.', retry_after: retryAfter, }), { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': String(retryAfter), ...headers, }, } ) } const response = await context.next() Object.entries(headers).forEach(([k, v]) => response.headers.set(k, v)) return response } } export const loginRateLimiter = createRateLimiter( (context) => { const ip = getClientIP(context.request) return `login:${ip}` }, 30, 60 ) export const filterRateLimiter = createRateLimiter( (context) => { const userId = context.data?.user_id || getClientIP(context.request) return `filter:${userId}` }, 600, 60 ) ================================================ FILE: tmarks/functions/lib/response.ts ================================================ import type { ApiResponse, ApiError } from './types' export function success(data: T, meta?: ApiResponse['meta']): Response { const body: ApiResponse = { data } if (meta) { body.meta = meta } return Response.json(body, { status: 200 }) } export function created(data: T): Response { return Response.json({ data } as ApiResponse, { status: 201 }) } export function noContent(): Response { return new Response(null, { status: 204 }) } export function badRequest(error: string | ApiError | Partial, code = 'BAD_REQUEST'): Response { const errorObj: ApiError = typeof error === 'string' ? { code, message: error } : { code: error.code || code, message: error.message || 'Bad request', ...error } return Response.json({ error: errorObj } as ApiResponse, { status: 400 }) } export function unauthorized(error: string | ApiError | Partial, code?: string): Response { const errorObj: ApiError = typeof error === 'string' ? { code: code || 'UNAUTHORIZED', message: error } : { code: error.code || code || 'UNAUTHORIZED', message: error.message || 'Unauthorized', ...error } return Response.json({ error: errorObj } as ApiResponse, { status: 401 }) } export function forbidden(error: string | ApiError | Partial, code?: string): Response { const errorObj: ApiError = typeof error === 'string' ? { code: code || 'FORBIDDEN', message: error } : { code: error.code || code || 'FORBIDDEN', message: error.message || 'Forbidden', ...error } return Response.json({ error: errorObj } as ApiResponse, { status: 403 }) } export function notFound(message = 'Not found', code = 'NOT_FOUND'): Response { const error: ApiError = { code, message } return Response.json({ error } as ApiResponse, { status: 404 }) } export function conflict(message: string, code = 'CONFLICT'): Response { const error: ApiError = { code, message } return Response.json({ error } as ApiResponse, { status: 409 }) } export function tooManyRequests(error: string | ApiError | Partial, headers?: Record): Response { const errorObj: ApiError = typeof error === 'string' ? { code: 'RATE_LIMIT_EXCEEDED', message: error } : { code: error.code || 'RATE_LIMIT_EXCEEDED', message: error.message || 'Too many requests', ...error } const responseHeaders = new Headers({ 'Content-Type': 'application/json' }) if (headers) { Object.entries(headers).forEach(([key, value]) => responseHeaders.set(key, value)) } return new Response(JSON.stringify({ error: errorObj } as ApiResponse), { status: 429, headers: responseHeaders, }) } export function internalError(message = 'Internal server error', code = 'INTERNAL_ERROR'): Response { const error: ApiError = { code, message } return Response.json({ error } as ApiResponse, { status: 500 }) } ================================================ FILE: tmarks/functions/lib/signed-url.ts ================================================ /** * Signed URL Generator * Generates secure signed URLs for temporary resource access * Similar to AWS S3 Presigned URLs */ export interface SignedUrlParams { userId: string resourceId: string // Resource ID (e.g. snapshot ID) expiresIn?: number // Expiration time (seconds), default 1 hour action?: string // Action type (e.g. 'view', 'download') } export interface SignedUrlData { userId: string resourceId: string expires: number // Unix timestamp action?: string } /** * Generate signed URL * @param params URL parameters * @param secret Secret key for signing * @returns Signature and expiration timestamp */ export async function generateSignedUrl( params: SignedUrlParams, secret: string ): Promise<{ signature: string; expires: number }> { const now = Math.floor(Date.now() / 1000) const expires = now + (params.expiresIn || 3600) // Default 1 hour const data: SignedUrlData = { userId: params.userId, resourceId: params.resourceId, expires, action: params.action, } // Generate signature const message = `${data.userId}:${data.resourceId}:${data.expires}:${data.action || ''}` const signature = await sign(message, secret) return { signature, expires } } /** * Verify signed URL * @param signature Signature string * @param expires Expiration timestamp * @param userId User ID * @param resourceId Resource ID * @param action Action type * @param secret Secret key for verification * @returns Validation result */ export async function verifySignedUrl( signature: string, expires: number, userId: string, resourceId: string, secret: string, action?: string ): Promise<{ valid: boolean; error?: string }> { // Check expiration const now = Math.floor(Date.now() / 1000) if (expires < now) { return { valid: false, error: 'URL has expired' } } // Verify signature const message = `${userId}:${resourceId}:${expires}:${action || ''}` const expectedSignature = await sign(message, secret) if (!timingSafeEqual(signature, expectedSignature)) { return { valid: false, error: 'Invalid signature' } } return { valid: true } } /** * Extract signed URL parameters from request */ export function extractSignedParams(request: Request): { signature: string | null expires: number | null userId: string | null action: string | null } { try { const url = new URL(request.url) const signature = url.searchParams.get('sig') || url.searchParams.get('signature') const expiresStr = url.searchParams.get('exp') || url.searchParams.get('expires') const userId = url.searchParams.get('u') || url.searchParams.get('user') const action = url.searchParams.get('a') || url.searchParams.get('action') return { signature, expires: expiresStr ? parseInt(expiresStr, 10) : null, userId, action, } } catch { return { signature: null, expires: null, userId: null, action: null, } } } /** * Generate HMAC-SHA256 signature */ function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false const encoder = new TextEncoder() const ab = encoder.encode(a) const bb = encoder.encode(b) let result = 0 for (let i = 0; i < ab.length; i++) { result |= ab[i] ^ bb[i] } return result === 0 } async function sign(message: string, secret: string): Promise { const encoder = new TextEncoder() const keyData = encoder.encode(secret) const messageData = encoder.encode(message) const key = await crypto.subtle.importKey( 'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ) const signature = await crypto.subtle.sign('HMAC', key, messageData) // Convert to hex string (lowercase) return Array.from(new Uint8Array(signature)) .map(b => b.toString(16).padStart(2, '0')) .join('') } ================================================ FILE: tmarks/functions/lib/storage-quota.ts ================================================ import type { Env } from './types' import type { D1Database } from '@cloudflare/workers-types' /** * R2 Storage Quota Management * * Calculation method: R2 total storage (snapshots + images) * Data source: Query D1 database bookmark_snapshots.file_size and bookmark_images.file_size */ // Note: If not configured or <= 0, means "unlimited" type UsageRow = { total: number | null } /** * Get R2 storage quota limit (bytes) * * Rules: * - Not configured/empty: "unlimited" * - Invalid format: "unlimited" * - <= 0: "unlimited" * - > 0: Use configured value */ export function getR2MaxTotalBytes(env: Env): number { const raw = env.R2_MAX_TOTAL_BYTES if (!raw || raw.trim() === '') { return Number.POSITIVE_INFINITY } const parsed = Number(raw) if (!Number.isFinite(parsed)) { console.warn('[StorageQuota] Invalid R2_MAX_TOTAL_BYTES, treating as unlimited', raw) return Number.POSITIVE_INFINITY } if (parsed <= 0) { return Number.POSITIVE_INFINITY } return parsed } /** * Get current R2 storage usage (bytes) * * Data sources: * - bookmark_snapshots.file_size: Snapshot HTML + images (V2 format) * - bookmark_images.file_size: Cover images (deduplicated by image_hash) */ export async function getCurrentR2UsageBytes(db: D1Database): Promise { const snapshotRow = await db .prepare('SELECT COALESCE(SUM(file_size), 0) AS total FROM bookmark_snapshots') .first() const snapshotsTotal = snapshotRow?.total ?? 0 let imagesTotal = 0 try { const imageRow = await db .prepare('SELECT COALESCE(SUM(file_size), 0) AS total FROM bookmark_images') .first() imagesTotal = imageRow?.total ?? 0 } catch (error) { console.warn('[StorageQuota] Failed to query bookmark_images usage', error) } return snapshotsTotal + imagesTotal } export interface R2QuotaCheckResult { allowed: boolean limitBytes: number usedBytes: number } /** * Check if adding additionalBytes would exceed quota */ export async function checkR2Quota( db: D1Database, env: Env, additionalBytes: number ): Promise { const limitBytes = getR2MaxTotalBytes(env) // Optimization: If unlimited, skip D1 query if (!Number.isFinite(limitBytes)) { return { allowed: true, limitBytes, usedBytes: 0 } } const usedBytes = await getCurrentR2UsageBytes(db) const willUse = usedBytes + Math.max(0, additionalBytes) if (willUse > limitBytes) { return { allowed: false, limitBytes, usedBytes } } return { allowed: true, limitBytes, usedBytes } } ================================================ FILE: tmarks/functions/lib/tags.ts ================================================ import { generateUUID } from './crypto' function normalizeTagNames(tagNames: string[]): string[] { const seen = new Set() const normalized: string[] = [] for (const rawName of tagNames) { const name = rawName.trim() if (!name) continue const key = name.toLowerCase() if (seen.has(key)) continue seen.add(key) normalized.push(name) } return normalized } function uniqueTagIds(tagIds: string[]): string[] { const seen = new Set() const uniqueIds: string[] = [] for (const rawId of tagIds) { const tagId = rawId.trim() if (!tagId || seen.has(tagId)) continue seen.add(tagId) uniqueIds.push(tagId) } return uniqueIds } export async function getValidTagIds( db: D1Database, userId: string, tagIds: string[] ): Promise { const requestedIds = uniqueTagIds(tagIds) if (requestedIds.length === 0) return [] const placeholders = requestedIds.map(() => '?').join(',') const { results } = await db.prepare( `SELECT id FROM tags WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL` ) .bind(...requestedIds, userId) .all<{ id: string }>() const validIds = new Set((results || []).map((row) => row.id)) return requestedIds.filter((tagId) => validIds.has(tagId)) } export async function resolveOrCreateTagIds( db: D1Database, userId: string, tagNames: string[], now: string = new Date().toISOString() ): Promise { const normalizedNames = normalizeTagNames(tagNames) if (normalizedNames.length === 0) return [] const placeholders = normalizedNames.map(() => '?').join(',') const { results: existingTags } = await db.prepare( `SELECT id, name FROM tags WHERE user_id = ? AND LOWER(name) IN (${placeholders}) AND deleted_at IS NULL` ) .bind(userId, ...normalizedNames.map((name) => name.toLowerCase())) .all<{ id: string; name: string }>() const tagMap = new Map() for (const tag of existingTags || []) { tagMap.set(tag.name.toLowerCase(), tag.id) } const tagsToCreate = normalizedNames.filter((name) => !tagMap.has(name.toLowerCase())) if (tagsToCreate.length > 0) { const insertStatements = tagsToCreate.map((name) => { const tagId = generateUUID() tagMap.set(name.toLowerCase(), tagId) return db .prepare('INSERT INTO tags (id, user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)') .bind(tagId, userId, name, now, now) }) await db.batch(insertStatements) } return normalizedNames .map((name) => tagMap.get(name.toLowerCase())) .filter((tagId): tagId is string => Boolean(tagId)) } export async function createOrLinkTags( db: D1Database, bookmarkId: string, tagNames: string[], userId: string ): Promise { const now = new Date().toISOString() const tagIds = await resolveOrCreateTagIds(db, userId, tagNames, now) if (tagIds.length === 0) return const linkStatements = tagIds.map((tagId) => db .prepare('INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id, user_id, created_at) VALUES (?, ?, ?, ?)') .bind(bookmarkId, tagId, userId, now) ) await db.batch(linkStatements) } export async function replaceBookmarkTags( db: D1Database, bookmarkId: string, userId: string, tagIds: string[], now: string = new Date().toISOString() ): Promise { const normalizedIds = uniqueTagIds(tagIds) const statements: D1PreparedStatement[] = [ db.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ? AND user_id = ?') .bind(bookmarkId, userId), ] for (const tagId of normalizedIds) { statements.push( db .prepare('INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id, user_id, created_at) VALUES (?, ?, ?, ?)') .bind(bookmarkId, tagId, userId, now) ) } await db.batch(statements) } export async function replaceBookmarkTagsByNames( db: D1Database, bookmarkId: string, tagNames: string[], userId: string, now: string = new Date().toISOString() ): Promise { const tagIds = await resolveOrCreateTagIds(db, userId, tagNames, now) await replaceBookmarkTags(db, bookmarkId, userId, tagIds, now) } ================================================ FILE: tmarks/functions/lib/types.ts ================================================ export interface Env { DB: D1Database // TMARKS_KV?: KVNamespace // Unified cache (public sharing, rate limiting, etc.) - Removed SNAPSHOTS_BUCKET?: R2Bucket // R2 bucket for bookmark snapshots R2_PUBLIC_URL?: string // (Optional) Public URL for R2 storage for cover images (e.g. https://r2.example.com) R2_MAX_TOTAL_BYTES?: string // R2 total storage quota (bytes), optional; not configured or <= 0 means unlimited CORS_ALLOWED_ORIGINS?: string // CORS allowed origins list (comma-separated, e.g. https://example.com,https://app.example.com) ALLOW_REGISTRATION?: string JWT_SECRET: string ENCRYPTION_KEY: string ENVIRONMENT?: string // 'development' | 'production' JWT_ACCESS_TOKEN_EXPIRES_IN?: string JWT_REFRESH_TOKEN_EXPIRES_IN?: string CACHE_LEVEL?: string // '0' | '1' | '2' | '3' | 'none' | 'minimal' | 'standard' | 'aggressive' ENABLE_KV_CACHE?: string // 'true' | 'false' CACHE_TTL_DEFAULT_LIST?: string CACHE_TTL_TAG_FILTER?: string CACHE_TTL_SEARCH?: string CACHE_TTL_PUBLIC_SHARE?: string ENABLE_MEMORY_CACHE?: string // 'true' | 'false' MEMORY_CACHE_MAX_AGE?: string CACHE_DEBUG?: string // 'true' | 'false' } export interface User { id: string username: string email: string | null password_hash: string created_at: string updated_at: string } export interface Bookmark { id: string user_id: string title: string url: string description: string | null cover_image: string | null favicon: string | null is_pinned: boolean is_archived: boolean is_public: boolean click_count: number last_clicked_at: string | null has_snapshot?: boolean latest_snapshot_at?: string | null snapshot_count?: number created_at: string updated_at: string deleted_at: string | null } export interface BookmarkRow extends Omit { is_pinned: number | boolean is_archived: number | boolean is_public: number | boolean } export interface PublicProfile { user_id: string public_share_enabled: boolean public_slug: string | null public_page_title: string | null public_page_description: string | null username: string } export interface Tag { id: string user_id: string name: string color: string | null click_count: number last_clicked_at: string | null created_at: string updated_at: string deleted_at: string | null } export interface ApiError { code: string message: string details?: unknown } export interface ApiResponse { data?: T error?: ApiError meta?: { page?: number page_size?: number total?: number next_cursor?: string } } export type RouteParams = Record export type SQLParam = string | number | boolean | null ================================================ FILE: tmarks/functions/lib/utils.ts ================================================ export function generateSlug(): string { const uuid = crypto.randomUUID().replace(/-/g, '') return uuid.slice(0, 10) } ================================================ FILE: tmarks/functions/lib/validation.ts ================================================ export function isValidEmail(email: string): boolean { if (email.length > 254) return false const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/ return emailRegex.test(email) } export function isValidUrl(url: string): boolean { try { const parsed = new URL(url) return parsed.protocol === 'http:' || parsed.protocol === 'https:' } catch { return false } } export function isValidUsername(username: string): boolean { // 3-20 ,、、 const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/ return usernameRegex.test(username) } export function isValidPassword(password: string): boolean { // 8 , return password.length >= 8 && /[a-zA-Z]/.test(password) && /[0-9]/.test(password) } export function sanitizeString(str: string, maxLength = 1000): string { return str.trim().slice(0, maxLength) } /** * Escape HTML special characters to prevent XSS */ export function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** * Sanitize and escape a string for safe HTML output */ export function sanitizeForHtml(str: string, maxLength = 1000): string { return escapeHtml(str.trim().slice(0, maxLength)) } ================================================ FILE: tmarks/functions/middleware/api-key-auth-pages.ts ================================================ /** * API Key Authentication Middleware for Cloudflare Pages Functions * Validates API Key and checks permissions for Pages Functions routes */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../lib/types' import { validateApiKey } from '../lib/api-key/validator' import { consumeRateLimit } from '../lib/api-key/rate-limiter' import { logApiKeyUsage } from '../lib/api-key/logger' import { unauthorized, forbidden, tooManyRequests } from '../lib/response' import { hasPermission } from '../../shared/permissions' export interface ApiKeyAuthContext extends Record { user_id: string api_key_id: string api_key_permissions: string[] } /** * Create API Key authentication middleware * @param requiredPermission Required permission string */ export function requireApiKeyAuth( requiredPermission: string ): PagesFunction { return async (context) => { const request = context.request try { // 1. Extract API Key const apiKey = request.headers.get('X-API-Key') if (!apiKey) { return unauthorized({ code: 'MISSING_API_KEY', message: 'API Key is required. Please provide X-API-Key header.', }) } // 2. Validate API Key const validation = await validateApiKey(apiKey, context.env.DB) if (!validation.valid || !validation.data || !validation.permissions) { return unauthorized({ code: 'INVALID_API_KEY', message: validation.error || 'Invalid API Key', }) } const { data: keyData, permissions } = validation // 3. Check permissions if (!hasPermission(permissions, requiredPermission)) { return forbidden({ code: 'INSUFFICIENT_PERMISSIONS', message: `Missing required permission: ${requiredPermission}`, required: requiredPermission, available: permissions, }) } // 4. Rate limiting (D1 based) const rateLimitResult = await consumeRateLimit(keyData.id, context.env.DB) const rateLimitHeaders: Record = { 'X-RateLimit-Limit': String(rateLimitResult.limit), 'X-RateLimit-Remaining': String(rateLimitResult.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset / 1000)), } if (!rateLimitResult.allowed) { const retryAfter = rateLimitResult.retryAfter || 0 return tooManyRequests( { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later.', }, { 'Retry-After': String(retryAfter), ...rateLimitHeaders, } ) } // 5. Get request IP const ip = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null // 6. Pass user info to context.data (for downstream handlers) context.data.user_id = keyData.user_id context.data.api_key_id = keyData.id context.data.api_key_permissions = permissions // 8. Update last used info (async, non-blocking) context.waitUntil( (async () => { try { await context.env.DB.prepare( `UPDATE api_keys SET last_used_at = datetime('now'), last_used_ip = ? WHERE id = ?` ) .bind(ip, keyData.id) .run() } catch (error) { console.error('Failed to update last_used:', error) } })() ) // 9. Log API usage (async, non-blocking) context.waitUntil( (async () => { try { await logApiKeyUsage( { api_key_id: keyData.id, user_id: keyData.user_id, endpoint: new URL(request.url).pathname, method: request.method, status: 200, // Default status, actual status logged after response ip, }, context.env.DB ) } catch (error) { console.error('Failed to log API usage:', error) } })() ) // 10. Continue to next handler (may return undefined, next() handles it) // Note: Pages Functions middleware can return undefined const response = await context.next() const headers = new Headers(response.headers) Object.entries(rateLimitHeaders).forEach(([k, v]) => headers.set(k, v)) return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }) } catch (error) { console.error('API Key auth middleware error:', error) return unauthorized({ code: 'AUTH_ERROR', message: 'Authentication failed', }) } } } ================================================ FILE: tmarks/functions/middleware/api-key-auth.ts ================================================ /** * API Key Authentication Middleware * Validates API Key and checks permissions */ import { Context } from 'hono' import { validateApiKey, checkPermission, updateLastUsed } from '../lib/api-key/validator' import { consumeRateLimit } from '../lib/api-key/rate-limiter' import { logApiKeyUsage } from '../lib/api-key/logger' interface ApiKeyAuthOptions { requiredPermission: string } /** * Create API Key authentication middleware * @param options Configuration options * @returns Middleware function */ export function requireApiKey(options: ApiKeyAuthOptions) { return async (c: Context, next: () => Promise) => { const { requiredPermission } = options // 1. Extract API Key const apiKey = c.req.header('X-API-Key') if (!apiKey) { return c.json( { error: { code: 'MISSING_API_KEY', message: 'API Key is required. Please provide X-API-Key header.', }, }, 401 ) } // 2. Validate API Key const validation = await validateApiKey(apiKey, c.env.DB) if (!validation.valid || !validation.data || !validation.permissions) { return c.json( { error: { code: 'INVALID_API_KEY', message: validation.error || 'Invalid API Key', }, }, 401 ) } const { data: keyData, permissions } = validation // 3. Check permissions if (!checkPermission(permissions, requiredPermission)) { return c.json( { error: { code: 'INSUFFICIENT_PERMISSIONS', message: `Missing required permission: ${requiredPermission}`, required: requiredPermission, available: permissions, }, }, 403 ) } // 4. Rate limiting (D1 based) const rateLimitResult = await consumeRateLimit(keyData.id, c.env.DB) if (!rateLimitResult.allowed) { return c.json( { error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later.', retry_after: rateLimitResult.retryAfter || 0, }, }, 429, { 'Retry-After': String(rateLimitResult.retryAfter || 0), 'X-RateLimit-Limit': String(rateLimitResult.limit), 'X-RateLimit-Remaining': String(rateLimitResult.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset / 1000)), } ) } // 5. Get request IP const ip = c.req.header('CF-Connecting-IP') || c.req.header('X-Forwarded-For') || null // 6. Update last used timestamp await updateLastUsed(keyData.id, ip, c.env.DB) // 7. Set context variables c.set('user_id', keyData.user_id) c.set('api_key_id', keyData.id) c.set('api_key_permissions', permissions) // 8. Continue to next handler await next() // 9. Log API usage const status = c.res.status const endpoint = c.req.path const method = c.req.method await logApiKeyUsage( { api_key_id: keyData.id, user_id: keyData.user_id, endpoint, method, status, ip, }, c.env.DB ) } } /** * Optional API Key authentication middleware * Validates API Key if present, continues without auth if not provided */ export function optionalApiKey() { return async (c: Context, next: () => Promise) => { const apiKey = c.req.header('X-API-Key') if (apiKey) { const validation = await validateApiKey(apiKey, c.env.DB) if (validation.valid && validation.data && validation.permissions) { c.set('user_id', validation.data.user_id) c.set('api_key_id', validation.data.id) c.set('api_key_permissions', validation.permissions) } } await next() } } ================================================ FILE: tmarks/functions/middleware/auth.ts ================================================ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../lib/types' import { extractJWT, verifyJWT } from '../lib/jwt' import { unauthorized } from '../lib/response' export interface AuthContext extends Record { user_id: string session_id?: string } /** * Authentication Middleware - Requires valid JWT token */ export const requireAuth: PagesFunction = async (context) => { const token = extractJWT(context.request) if (!token) { return unauthorized('Missing authorization token') } try { const payload = await verifyJWT(token, context.env.JWT_SECRET) // Pass user info to context.data context.data.user_id = payload.sub context.data.session_id = payload.session_id return context.next() } catch (error) { const message = error instanceof Error ? error.message : 'Invalid token' return unauthorized(message) } } /** * Optional Authentication Middleware - Validates token if present, continues if not */ export const optionalAuth: PagesFunction> = async (context) => { const token = extractJWT(context.request) if (token) { try { const payload = await verifyJWT(token, context.env.JWT_SECRET) context.data.user_id = payload.sub context.data.session_id = payload.session_id } catch { // Ignore invalid token, continue without auth } } return context.next() } ================================================ FILE: tmarks/functions/middleware/dual-auth.ts ================================================ /** * Dual Authentication Middleware * Supports both JWT Token and API Key authentication methods */ import type { PagesFunction } from '@cloudflare/workers-types' import type { Env, RouteParams } from '../lib/types' import { validateApiKey } from '../lib/api-key/validator' import { consumeRateLimit } from '../lib/api-key/rate-limiter' import { hasPermission } from '../../shared/permissions' import { unauthorized, forbidden, tooManyRequests } from '../lib/response' import { verifyJWT } from '../lib/jwt' export interface DualAuthContext { user_id: string auth_type: 'jwt' | 'api_key' api_key_id?: string api_key_permissions?: string[] } /** * Create dual authentication middleware * @param requiredPermission Required permission (only for API Key authentication) */ export function requireDualAuth( requiredPermission: string ): PagesFunction { return async (context) => { const request = context.request try { // 1. Check for API Key const apiKey = request.headers.get('X-API-Key') if (apiKey) { // API Key authentication flow const validation = await validateApiKey(apiKey, context.env.DB) if (!validation.valid || !validation.data || !validation.permissions) { return unauthorized({ code: 'INVALID_API_KEY', message: validation.error || 'Invalid API Key', }) } const { data: keyData, permissions } = validation // Check permissions if (!hasPermission(permissions, requiredPermission)) { return forbidden({ code: 'INSUFFICIENT_PERMISSIONS', message: `Missing required permission: ${requiredPermission}`, required: requiredPermission, available: permissions, }) } // Rate limiting (D1) const rateLimitResult = await consumeRateLimit(keyData.id, context.env.DB) if (!rateLimitResult.allowed) { const headers: Record = { 'Retry-After': String(rateLimitResult.retryAfter || 0), 'X-RateLimit-Limit': String(rateLimitResult.limit), 'X-RateLimit-Remaining': String(rateLimitResult.remaining), 'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset / 1000)), } return tooManyRequests( { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many requests. Please try again later.', }, headers ) } // Get request IP const ip = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || null // Pass user info to context.data context.data.user_id = keyData.user_id context.data.auth_type = 'api_key' context.data.api_key_id = keyData.id context.data.api_key_permissions = permissions // Update last used info (async) context.waitUntil( (async () => { try { await context.env.DB.prepare( `UPDATE api_keys SET last_used_at = datetime('now'), last_used_ip = ? WHERE id = ?` ) .bind(ip, keyData.id) .run() } catch (error) { console.error('Failed to update last_used:', error) } })() ) return context.next() } // 2. Check for JWT Token const authHeader = request.headers.get('Authorization') if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7) try { const payload = await verifyJWT(token, context.env.JWT_SECRET) if (!payload || !payload.sub) { return unauthorized({ code: 'INVALID_TOKEN', message: 'Invalid or expired token', }) } // Pass user info to context.data context.data.user_id = payload.sub context.data.auth_type = 'jwt' return context.next() } catch { return unauthorized({ code: 'INVALID_TOKEN', message: 'Invalid or expired token', }) } } // 3. No authentication provided return unauthorized({ code: 'MISSING_AUTH', message: 'Authentication required. Provide either X-API-Key header or Bearer token.', }) } catch (error) { console.error('Dual auth middleware error:', error) return unauthorized({ code: 'AUTH_ERROR', message: 'Authentication failed', }) } } } ================================================ FILE: tmarks/functions/middleware/index.ts ================================================ // ============ Middleware Exports ============ export * from './api-key-auth'; export * from './api-key-auth-pages'; export * from './auth'; export * from './dual-auth'; export * from './security'; ================================================ FILE: tmarks/functions/middleware/security.ts ================================================ /** * Security Middleware * Provides security headers, CSP policies, input validation, and other security features */ import type { PagesFunction } from '@cloudflare/workers-types' /** * Security Headers Middleware */ export const securityHeaders: PagesFunction = async (context) => { const response = await context.next() // Create new response headers const newHeaders = new Headers(response.headers) // Check if this is a snapshot view path (these paths need relaxed CSP) const url = new URL(context.request.url) const isSnapshotView = url.pathname.includes('/snapshots/') && (url.pathname.includes('/view') || url.searchParams.has('sig')) const standardCsp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data:", "connect-src 'self' https:", "object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ].join('; ') // Snapshot view should not execute scripts from captured HTML. const snapshotCsp = [ "default-src 'none'", "script-src 'none'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' data:", "connect-src 'none'", "media-src 'self' data: https:", "object-src 'none'", "frame-ancestors 'none'", "base-uri 'none'", "form-action 'none'", ].join('; ') // Security headers configuration const securityHeaders = { // Prevent clickjacking (except for snapshot views) ...(!isSnapshotView && { 'X-Frame-Options': 'DENY' }), // Prevent MIME type sniffing 'X-Content-Type-Options': 'nosniff', // XSS protection 'X-XSS-Protection': '1; mode=block', // Referrer policy 'Referrer-Policy': 'strict-origin-when-cross-origin', // Permissions policy 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()', // Content Security Policy 'Content-Security-Policy': isSnapshotView ? snapshotCsp : standardCsp, // HSTS (only in HTTPS environment) ...(context.request.url.startsWith('https://') && { 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload' }) } // Add security headers (skip undefined values) Object.entries(securityHeaders).forEach(([key, value]) => { if (value) { newHeaders.set(key, value) } }) return new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders, }) } /** * CORS Configuration Middleware */ export const corsHeaders: PagesFunction = async (context) => { // Get allowed origins from environment variables const allowedOriginsEnv = (context.env as { CORS_ALLOWED_ORIGINS?: string })?.CORS_ALLOWED_ORIGINS const cors = getCorsPolicy(context.request, allowedOriginsEnv) // Handle preflight requests if (context.request.method === 'OPTIONS') { const headers: Record = { 'Access-Control-Allow-Origin': cors.origin, 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key', 'Access-Control-Max-Age': '86400', 'Vary': 'Origin', } if (cors.allowCredentials) { headers['Access-Control-Allow-Credentials'] = 'true' } return new Response(null, { headers, }) } const response = await context.next() const newHeaders = new Headers(response.headers) // Add CORS headers newHeaders.set('Access-Control-Allow-Origin', cors.origin) if (cors.allowCredentials) { newHeaders.set('Access-Control-Allow-Credentials', 'true') } else { newHeaders.delete('Access-Control-Allow-Credentials') } newHeaders.set('Vary', 'Origin') return new Response(response.body, { status: response.status, statusText: response.statusText, headers: newHeaders, }) } /** * Get allowed origins * @param request Request object * @param allowedOriginsEnv Allowed origins list from environment variables (comma-separated) */ function getCorsPolicy(request: Request, allowedOriginsEnv?: string): { origin: string; allowCredentials: boolean } { const origin = request.headers.get('Origin') const defaultOrigins = [ 'http://localhost:5173', 'http://localhost:3000', ] const envOrigins = allowedOriginsEnv ? allowedOriginsEnv.split(',').map(o => o.trim()).filter(Boolean) : [] const allowedOrigins = [...defaultOrigins, ...envOrigins] // Browser extensions must be explicitly listed in CORS_ALLOWED_ORIGINS // Example: chrome-extension://abcdef123456,extension://abcdef123456 if (origin && (origin.startsWith('chrome-extension://') || origin.startsWith('extension://'))) { if (allowedOrigins.includes(origin)) { return { origin, allowCredentials: true } } // Development fallback: allow unconfigured extensions only when localhost origins exist const hasExtensionWhitelist = allowedOrigins.some( o => o.startsWith('chrome-extension://') || o.startsWith('extension://') ) if (!hasExtensionWhitelist && allowedOrigins.some(o => o.includes('localhost'))) { return { origin, allowCredentials: true } } return { origin: 'null', allowCredentials: false } } if (origin && allowedOrigins.includes(origin)) { return { origin, allowCredentials: true } } if (!origin) { return { origin: '*', allowCredentials: false } } return { origin: 'null', allowCredentials: false } } /** * Input Validation Middleware */ export function validateInput(validator: (data: unknown) => data is T) { return async (context: { request: Request; next: () => Promise; validatedData?: T }) => { if (context.request.method === 'POST' || context.request.method === 'PUT' || context.request.method === 'PATCH') { try { const body = await context.request.json() if (!validator(body)) { return new Response( JSON.stringify({ error: { code: 'INVALID_INPUT', message: 'Invalid request body format' } }), { status: 400, headers: { 'Content-Type': 'application/json' } } ) } // Attach validated data to context context.validatedData = body } catch { return new Response( JSON.stringify({ error: { code: 'INVALID_JSON', message: 'Invalid JSON format' } }), { status: 400, headers: { 'Content-Type': 'application/json' } } ) } } return context.next() } } /** * Rate Limiting Middleware (IP-based) * Note: This function currently serves as a placeholder, actual rate limiting logic is implemented in rate-limit.ts */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export function rateLimitByIP(_limit: number, _windowSeconds: number) { return async (context: { request: Request; next: () => Promise }) => { // Get IP address for future rate limiting implementation // const ip = context.request.headers.get('CF-Connecting-IP') || // context.request.headers.get('X-Forwarded-For') || // 'unknown' // This can be integrated into the existing rate limiting system // For now, continue execution return context.next() } } /** * Request Logging Middleware */ export const requestLogger: PagesFunction = async (context) => { const start = Date.now() const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown' const userAgent = context.request.headers.get('User-Agent') || 'unknown' // Remove sensitive query parameters from logged URL const logUrl = new URL(context.request.url) for (const param of ['sig', 'token', 'api_key', 'key']) { if (logUrl.searchParams.has(param)) { logUrl.searchParams.set(param, '***') } } const sanitizedUrl = logUrl.toString() try { const response = await context.next() const duration = Date.now() - start // Log request console.log(JSON.stringify({ timestamp: new Date().toISOString(), method: context.request.method, url: sanitizedUrl, status: response.status, duration, ip, userAgent: userAgent.substring(0, 100), })) return response } catch (error) { const duration = Date.now() - start // Log error console.error(JSON.stringify({ timestamp: new Date().toISOString(), method: context.request.method, url: sanitizedUrl, error: error instanceof Error ? error.message : 'Unknown error', duration, ip, userAgent: userAgent.substring(0, 100), })) throw error } } /** * Combined Security Middleware */ export const securityMiddleware: PagesFunction = async (context) => { // Apply security middleware in sequence return securityHeaders(context) } ================================================ FILE: tmarks/index.html ================================================ TMarks - 书签管理

================================================ FILE: tmarks/migrations/0001_d1_console.sql ================================================ CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', public_share_enabled INTEGER NOT NULL DEFAULT 0, public_slug TEXT, public_page_title TEXT, public_page_description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))); CREATE INDEX IF NOT EXISTS idx_users_username_lower ON users(LOWER(username)); CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users(LOWER(email)); CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_public_slug ON users(public_slug) WHERE public_slug IS NOT NULL; CREATE TABLE IF NOT EXISTS auth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, refresh_token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(refresh_token_hash); CREATE INDEX IF NOT EXISTS idx_auth_tokens_expires ON auth_tokens(expires_at); CREATE TABLE IF NOT EXISTS bookmarks (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, description TEXT, cover_image TEXT, cover_image_id TEXT, favicon TEXT, is_pinned INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0, is_public INTEGER NOT NULL DEFAULT 0, click_count INTEGER NOT NULL DEFAULT 0, last_clicked_at TEXT, has_snapshot INTEGER NOT NULL DEFAULT 0, latest_snapshot_at TEXT, snapshot_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, url)); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON bookmarks(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, url); CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_deleted ON bookmarks(user_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_bookmarks_pinned ON bookmarks(user_id, is_pinned, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_click_count ON bookmarks(user_id, click_count DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_last_clicked ON bookmarks(user_id, last_clicked_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_created ON bookmarks(user_id, is_archived, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_updated ON bookmarks(user_id, is_archived, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_created ON bookmarks(user_id, is_archived, is_pinned DESC, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_updated ON bookmarks(user_id, is_archived, is_pinned DESC, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_clicks ON bookmarks(user_id, is_archived, is_pinned DESC, click_count DESC, last_clicked_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_user_deleted_created ON bookmarks(user_id, deleted_at, created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_bookmarks_has_snapshot ON bookmarks(user_id, has_snapshot, created_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmarks_cover_image_id ON bookmarks(cover_image_id); CREATE TABLE IF NOT EXISTS tags (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT, click_count INTEGER NOT NULL DEFAULT 0, last_clicked_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, name)); CREATE INDEX IF NOT EXISTS idx_tags_user_name ON tags(user_id, LOWER(name)); CREATE INDEX IF NOT EXISTS idx_tags_user_deleted ON tags(user_id, deleted_at); CREATE INDEX IF NOT EXISTS idx_tags_click_count ON tags(user_id, click_count DESC); CREATE INDEX IF NOT EXISTS idx_tags_last_clicked ON tags(user_id, last_clicked_at DESC); CREATE TABLE IF NOT EXISTS bookmark_tags (bookmark_id TEXT NOT NULL, tag_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (bookmark_id, tag_id), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_bookmark_tags_tag_user ON bookmark_tags(tag_id, user_id); CREATE INDEX IF NOT EXISTS idx_bookmark_tags_bookmark ON bookmark_tags(bookmark_id); CREATE TABLE IF NOT EXISTS bookmark_snapshots (id TEXT PRIMARY KEY, bookmark_id TEXT NOT NULL, user_id TEXT NOT NULL, version INTEGER NOT NULL, is_latest INTEGER NOT NULL DEFAULT 0, content_hash TEXT NOT NULL, r2_key TEXT NOT NULL, r2_bucket TEXT NOT NULL DEFAULT 'tmarks-snapshots', file_size INTEGER NOT NULL, mime_type TEXT NOT NULL DEFAULT 'text/html', snapshot_url TEXT NOT NULL, snapshot_title TEXT NOT NULL, snapshot_status TEXT NOT NULL DEFAULT 'completed', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_id ON bookmark_snapshots(bookmark_id); CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_user_id ON bookmark_snapshots(user_id); CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_created_at ON bookmark_snapshots(created_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_content_hash ON bookmark_snapshots(content_hash); CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_latest ON bookmark_snapshots(bookmark_id, is_latest DESC); CREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_version ON bookmark_snapshots(bookmark_id, version DESC); CREATE TABLE IF NOT EXISTS bookmark_images (id TEXT PRIMARY KEY, bookmark_id TEXT NOT NULL, user_id TEXT NOT NULL, image_hash TEXT NOT NULL, r2_key TEXT NOT NULL, r2_bucket TEXT NOT NULL DEFAULT 'tmarks-snapshots', file_size INTEGER NOT NULL, mime_type TEXT NOT NULL, original_url TEXT NOT NULL, width INTEGER, height INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_bookmark_images_bookmark_id ON bookmark_images(bookmark_id); CREATE INDEX IF NOT EXISTS idx_bookmark_images_user_id ON bookmark_images(user_id); CREATE INDEX IF NOT EXISTS idx_bookmark_images_hash ON bookmark_images(image_hash); CREATE INDEX IF NOT EXISTS idx_bookmark_images_created_at ON bookmark_images(created_at DESC); CREATE TABLE IF NOT EXISTS user_preferences (user_id TEXT PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'light', page_size INTEGER NOT NULL DEFAULT 30, view_mode TEXT NOT NULL DEFAULT 'list', density TEXT NOT NULL DEFAULT 'normal', tag_layout TEXT NOT NULL DEFAULT 'grid', sort_by TEXT NOT NULL DEFAULT 'popular', search_auto_clear_seconds INTEGER NOT NULL DEFAULT 15, tag_selection_auto_clear_seconds INTEGER NOT NULL DEFAULT 30, enable_search_auto_clear INTEGER NOT NULL DEFAULT 1, enable_tag_selection_auto_clear INTEGER NOT NULL DEFAULT 0, default_bookmark_icon TEXT NOT NULL DEFAULT 'gradient-glow', snapshot_retention_count INTEGER NOT NULL DEFAULT 5, snapshot_auto_create INTEGER NOT NULL DEFAULT 0, snapshot_auto_dedupe INTEGER NOT NULL DEFAULT 1, snapshot_auto_cleanup_days INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS api_keys (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, key_prefix TEXT NOT NULL, name TEXT NOT NULL, description TEXT, permissions TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', expires_at TEXT, last_used_at TEXT, last_used_ip TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id); CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(user_id, status); CREATE TABLE IF NOT EXISTS api_key_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, api_key_id TEXT NOT NULL, user_id TEXT NOT NULL, endpoint TEXT NOT NULL, method TEXT NOT NULL, status INTEGER NOT NULL, ip TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_api_logs_key ON api_key_logs(api_key_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_api_logs_user ON api_key_logs(user_id, created_at DESC); CREATE TABLE IF NOT EXISTS tab_groups (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, parent_id TEXT DEFAULT NULL, is_folder INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, color TEXT DEFAULT NULL, tags TEXT DEFAULT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, deleted_at TEXT DEFAULT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_tab_groups_user_created ON tab_groups(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_tab_groups_user_id ON tab_groups(user_id); CREATE INDEX IF NOT EXISTS idx_tab_groups_parent_id ON tab_groups(parent_id); CREATE INDEX IF NOT EXISTS idx_tab_groups_is_folder ON tab_groups(is_folder); CREATE INDEX IF NOT EXISTS idx_tab_groups_user_parent ON tab_groups(user_id, parent_id); CREATE INDEX IF NOT EXISTS idx_tab_groups_parent_position ON tab_groups(parent_id, position ASC); CREATE INDEX IF NOT EXISTS idx_tab_groups_user_parent_position ON tab_groups(user_id, parent_id, position ASC); CREATE INDEX IF NOT EXISTS idx_tab_groups_deleted ON tab_groups(user_id, is_deleted); CREATE TABLE IF NOT EXISTS tab_group_items (id TEXT PRIMARY KEY, group_id TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, favicon TEXT, position INTEGER NOT NULL, is_pinned INTEGER NOT NULL DEFAULT 0, is_todo INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (group_id) REFERENCES tab_groups(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_tab_group_items_group_id ON tab_group_items(group_id, position ASC); CREATE INDEX IF NOT EXISTS idx_tab_group_items_group_created ON tab_group_items(group_id, created_at ASC); CREATE INDEX IF NOT EXISTS idx_tab_group_items_pinned ON tab_group_items(group_id, is_pinned DESC, position ASC); CREATE INDEX IF NOT EXISTS idx_tab_group_items_archived ON tab_group_items(group_id, is_archived, position ASC); CREATE INDEX IF NOT EXISTS idx_tab_group_items_not_archived ON tab_group_items(group_id, is_archived) WHERE is_archived = 0; CREATE TABLE IF NOT EXISTS shares (id TEXT PRIMARY KEY, group_id TEXT NOT NULL, user_id TEXT NOT NULL, share_token TEXT NOT NULL UNIQUE, is_public INTEGER DEFAULT 1, view_count INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT DEFAULT NULL, FOREIGN KEY (group_id) REFERENCES tab_groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(share_token); CREATE INDEX IF NOT EXISTS idx_shares_group_id ON shares(group_id); CREATE INDEX IF NOT EXISTS idx_shares_user_id ON shares(user_id); CREATE TABLE IF NOT EXISTS statistics (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, stat_date TEXT NOT NULL, groups_created INTEGER DEFAULT 0, groups_deleted INTEGER DEFAULT 0, items_added INTEGER DEFAULT 0, items_deleted INTEGER DEFAULT 0, shares_created INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_statistics_user_date ON statistics(user_id, stat_date); CREATE INDEX IF NOT EXISTS idx_statistics_user_id ON statistics(user_id); CREATE INDEX IF NOT EXISTS idx_statistics_date ON statistics(stat_date); CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, event_type TEXT NOT NULL, payload TEXT, ip TEXT, user_agent TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL); CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_logs_event ON audit_logs(event_type, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC); CREATE TABLE IF NOT EXISTS registration_limits (date TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now'))); CREATE TABLE IF NOT EXISTS bookmark_click_events (id INTEGER PRIMARY KEY AUTOINCREMENT, bookmark_id TEXT NOT NULL, user_id TEXT NOT NULL, clicked_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_bookmark_click_events_user_clicked_at ON bookmark_click_events(user_id, clicked_at DESC); CREATE INDEX IF NOT EXISTS idx_bookmark_click_events_bookmark_clicked_at ON bookmark_click_events(bookmark_id, clicked_at DESC); CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now'))); INSERT OR IGNORE INTO schema_migrations (version) VALUES ('0001'); ================================================ FILE: tmarks/migrations/0002_d1_console_ai_settings.sql ================================================ CREATE TABLE IF NOT EXISTS ai_settings ( id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), user_id TEXT NOT NULL UNIQUE, provider TEXT NOT NULL DEFAULT 'openai', api_keys_encrypted TEXT, api_urls TEXT, model TEXT, custom_prompt TEXT, enable_custom_prompt INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_ai_settings_user_id ON ai_settings(user_id); INSERT OR IGNORE INTO schema_migrations (version) VALUES ('0002'); ================================================ FILE: tmarks/migrations/0103_api_key_rate_limits.sql ================================================ CREATE TABLE IF NOT EXISTS api_key_rate_limits ( api_key_id TEXT NOT NULL, window TEXT NOT NULL, -- minute|hour|day window_start INTEGER NOT NULL, -- unix ms aligned to window count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (api_key_id, window, window_start) ); CREATE INDEX IF NOT EXISTS idx_api_key_rate_limits_updated_at ON api_key_rate_limits(updated_at); INSERT OR IGNORE INTO schema_migrations (version) VALUES ('0103'); ================================================ FILE: tmarks/migrations/0104_rate_limits.sql ================================================ CREATE TABLE IF NOT EXISTS rate_limits ( key TEXT NOT NULL, window_seconds INTEGER NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (key, window_seconds, window_start) ); CREATE INDEX IF NOT EXISTS idx_rate_limits_updated_at ON rate_limits(updated_at); INSERT OR IGNORE INTO schema_migrations (version) VALUES ('0104'); ================================================ FILE: tmarks/package.json ================================================ { "name": "tmarks", "version": "0.1.2", "private": true, "license": "CC-BY-NC-4.0", "type": "module", "scripts": { "dev": "vite --host", "build": "tsc && vite build", "build:deploy": "pnpm build && node scripts/prepare-deploy.js", "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "type-check": "tsc --noEmit", "cf:dev": "wrangler dev", "cf:deploy": "wrangler deploy", "cf:tail": "wrangler tail", "db:migrate": "wrangler d1 migrations apply tmarks-prod-db", "db:migrate:local": "wrangler d1 migrations apply tmarks-prod-db --local", "db:auto-migrate": "node scripts/auto-migrate.js", "db:auto-migrate:local": "node scripts/auto-migrate.js --local", "db:check": "node scripts/check-db-schema.js", "db:check:local": "node scripts/check-db-schema.js --local", "deploy": "pwsh scripts/deploy.ps1", "prepare": "husky" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tanstack/query-sync-storage-persister": "^5.90.12", "@tanstack/react-query": "^5.90.10", "@tanstack/react-query-persist-client": "^5.90.12", "@tanstack/react-virtual": "^3.10.8", "date-fns": "^4.1.0", "i18next": "^25.7.3", "i18next-browser-languagedetector": "^8.2.0", "lodash-es": "^4.17.21", "lucide-react": "^0.545.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.0", "react-router-dom": "^7.0.1", "simple-icons": "^15.22.0", "zustand": "^5.0.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251008.0", "@eslint/js": "^9.15.0", "@tailwindcss/postcss": "^4.1.14", "@types/lodash-es": "^4.17.12", "@types/node": "^22.10.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "@vitejs/plugin-react-swc": "^3.7.1", "autoprefixer": "^10.4.20", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "fast-check": "^4.3.0", "globals": "^15.12.0", "husky": "^9.1.7", "javascript-obfuscator": "^4.1.1", "lint-staged": "^15.2.10", "postcss": "^8.4.49", "prettier": "^3.3.3", "rollup-plugin-visualizer": "^6.0.4", "tailwindcss": "^4.0.0", "terser": "^5.44.0", "typescript": "^5.6.3", "typescript-eslint": "^8.15.0", "vite": "^6.0.1", "vite-plugin-compression": "^0.5.1", "vite-plugin-obfuscator": "^1.0.5", "vitest": "^2.1.5", "wrangler": "^4.42.1" }, "lint-staged": { "*.{ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{css,md}": [ "prettier --write" ] } } ================================================ FILE: tmarks/postcss.config.js ================================================ export default { plugins: { '@tailwindcss/postcss': {}, autoprefixer: {}, }, } ================================================ FILE: tmarks/public/_headers ================================================ /* X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Strict-Transport-Security: max-age=63072000; includeSubDomains; preload /assets/* Cache-Control: public, max-age=31536000, immutable ================================================ FILE: tmarks/public/_routes.json ================================================ { "version": 1, "include": [ "/api/*" ], "exclude": [] } ================================================ FILE: tmarks/scripts/auto-migrate.js ================================================ #!/usr/bin/env node /** * 自动数据库迁移脚本 * * 功能: * 1. 检测新的迁移文件 * 2. 自动执行未应用的迁移 * 3. 记录迁移历史 * * 使用方式: * - 本地开发: pnpm db:auto-migrate:local * - 生产环境: pnpm db:auto-migrate */ import { execSync } from 'child_process' import { readFileSync, existsSync, writeFileSync, readdirSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const MIGRATIONS_DIR = join(__dirname, '../migrations') const MIGRATION_HISTORY_FILE = join(__dirname, '../.migration-history.json') // 颜色输出 const colors = { reset: '\x1b[0m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', blue: '\x1b[34m', gray: '\x1b[90m', } function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`) } // 读取迁移历史 function getMigrationHistory() { if (!existsSync(MIGRATION_HISTORY_FILE)) { return { migrations: [] } } try { return JSON.parse(readFileSync(MIGRATION_HISTORY_FILE, 'utf-8')) } catch (error) { log(`⚠️ 无法读取迁移历史: ${error.message}`, 'yellow') return { migrations: [] } } } // 保存迁移历史 function saveMigrationHistory(history) { writeFileSync(MIGRATION_HISTORY_FILE, JSON.stringify(history, null, 2)) } // 获取所有迁移文件(按编号排序) function getMigrationFiles() { const files = readdirSync(MIGRATIONS_DIR) .filter(file => { // 只处理编号开头的 SQL 文件 return /^\d{4}_.*\.sql$/.test(file) }) .sort() return files } // 执行迁移 function executeMigration(filename, isLocal = false) { const filepath = join(MIGRATIONS_DIR, filename) const sql = readFileSync(filepath, 'utf-8') // 跳过空文件或只有注释的文件 const hasContent = sql.split('\n').some(line => { const trimmed = line.trim() return trimmed && !trimmed.startsWith('--') }) if (!hasContent) { log(` ⏭️ 跳过空文件: ${filename}`, 'gray') return true } try { const dbName = 'tmarks-prod-db' const localFlag = isLocal ? '--local' : '' log(` 📝 执行迁移: ${filename}`, 'blue') // 使用 wrangler d1 execute 执行 SQL const command = `wrangler d1 execute ${dbName} --file="${filepath}" ${localFlag}`.trim() execSync(command, { stdio: 'inherit', cwd: join(__dirname, '..') }) log(` ✅ 成功: ${filename}`, 'green') return true } catch (error) { log(` ❌ 失败: ${filename}`, 'red') log(` ${error.message}`, 'red') return false } } // 主函数 function main() { const isLocal = process.argv.includes('--local') const force = process.argv.includes('--force') log('\n🚀 开始数据库迁移检查...\n', 'blue') log(`环境: ${isLocal ? '本地开发' : '生产环境'}`, 'gray') // 读取迁移历史 const history = getMigrationHistory() const appliedMigrations = new Set(history.migrations || []) // 获取所有迁移文件 const migrationFiles = getMigrationFiles() if (migrationFiles.length === 0) { log('\n⚠️ 未找到迁移文件(格式: 0001_xxx.sql)', 'yellow') log(' 迁移文件应该以 4 位数字开头,例如: 0003_add_general_settings.sql\n', 'gray') return } log(`\n找到 ${migrationFiles.length} 个迁移文件:\n`, 'gray') // 找出未应用的迁移 const pendingMigrations = migrationFiles.filter(file => { const isApplied = appliedMigrations.has(file) const status = isApplied ? '✓' : '○' const color = isApplied ? 'green' : 'yellow' log(` ${status} ${file}`, color) return !isApplied || force }) if (pendingMigrations.length === 0) { log('\n✨ 所有迁移已应用,无需操作\n', 'green') return } log(`\n📦 需要应用 ${pendingMigrations.length} 个迁移:\n`, 'yellow') // 执行迁移 let successCount = 0 let failCount = 0 for (const file of pendingMigrations) { const success = executeMigration(file, isLocal) if (success) { successCount++ // 记录到历史 if (!appliedMigrations.has(file)) { history.migrations = history.migrations || [] history.migrations.push(file) history.migrations.sort() } } else { failCount++ // 失败则停止 break } } // 保存迁移历史 if (successCount > 0) { history.lastUpdated = new Date().toISOString() saveMigrationHistory(history) } // 输出结果 log('\n' + '='.repeat(50), 'gray') if (failCount === 0) { log(`\n✅ 迁移完成!成功: ${successCount}`, 'green') } else { log(`\n⚠️ 迁移部分完成。成功: ${successCount}, 失败: ${failCount}`, 'yellow') log(' 请检查错误信息并手动修复', 'yellow') } log('') } // 运行 try { main() } catch (error) { log(`\n❌ 迁移失败: ${error.message}\n`, 'red') process.exit(1) } ================================================ FILE: tmarks/scripts/check-db-schema.js ================================================ #!/usr/bin/env node /** * 检查数据库结构是否完整 * 用法: node scripts/check-db-schema.js [--local] */ import { execSync } from 'child_process'; const isLocal = process.argv.includes('--local'); const localFlag = isLocal ? '--local' : ''; console.log(`🔍 检查数据库结构 (${isLocal ? '本地' : '生产'}环境)...\n`); // 必需的表 const requiredTables = [ 'users', 'bookmarks', 'tags', 'bookmark_tags', 'user_preferences', 'bookmark_snapshots', 'bookmark_images', 'api_keys', ]; // bookmarks表必需的字段 const requiredBookmarkFields = [ 'id', 'user_id', 'title', 'url', 'description', 'cover_image', 'cover_image_id', 'favicon', 'has_snapshot', 'latest_snapshot_at', 'snapshot_count', 'is_pinned', 'is_archived', 'is_public', 'click_count', 'last_clicked_at', 'created_at', 'updated_at', 'deleted_at', ]; // user_preferences表必需的字段 const requiredPreferenceFields = [ 'user_id', 'theme', 'page_size', 'view_mode', 'density', 'tag_layout', 'sort_by', 'search_auto_clear_seconds', 'tag_selection_auto_clear_seconds', 'enable_search_auto_clear', 'enable_tag_selection_auto_clear', 'default_bookmark_icon', 'snapshot_retention_count', 'snapshot_auto_create', 'snapshot_auto_dedupe', 'snapshot_auto_cleanup_days', 'updated_at', ]; function executeQuery(query) { try { const command = `pnpm wrangler d1 execute tmarks-prod-db ${localFlag} --command="${query}"`; const result = execSync(command, { encoding: 'utf-8' }); return result; } catch (error) { return null; } } function checkTable(tableName) { console.log(`📋 检查表: ${tableName}`); const result = executeQuery(`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`); if (result && result.includes(tableName)) { console.log(` ✅ 表存在\n`); return true; } else { console.log(` ❌ 表不存在\n`); return false; } } function checkTableFields(tableName, requiredFields) { console.log(`🔍 检查表字段: ${tableName}`); const result = executeQuery(`PRAGMA table_info(${tableName})`); if (!result) { console.log(` ❌ 无法获取表结构\n`); return false; } const missingFields = []; for (const field of requiredFields) { if (result.includes(field)) { console.log(` ✅ ${field}`); } else { console.log(` ❌ ${field} (缺失)`); missingFields.push(field); } } console.log(); if (missingFields.length > 0) { console.log(`⚠️ 缺失字段: ${missingFields.join(', ')}\n`); return false; } return true; } function checkMigrations() { console.log(`📜 检查迁移记录`); const result = executeQuery(`SELECT version FROM schema_migrations ORDER BY version`); if (result) { console.log(result); } else { console.log(` ❌ 无法获取迁移记录\n`); } } // 主检查流程 let allGood = true; console.log('='.repeat(60)); console.log('检查必需的表'); console.log('='.repeat(60) + '\n'); for (const table of requiredTables) { if (!checkTable(table)) { allGood = false; } } console.log('='.repeat(60)); console.log('检查bookmarks表字段'); console.log('='.repeat(60) + '\n'); if (!checkTableFields('bookmarks', requiredBookmarkFields)) { allGood = false; } console.log('='.repeat(60)); console.log('检查user_preferences表字段'); console.log('='.repeat(60) + '\n'); if (!checkTableFields('user_preferences', requiredPreferenceFields)) { allGood = false; } console.log('='.repeat(60)); checkMigrations(); console.log('='.repeat(60) + '\n'); if (allGood) { console.log('✅ 数据库结构完整!\n'); process.exit(0); } else { console.log('❌ 数据库结构不完整,请查看上面的错误信息\n'); console.log('💡 修复建议:'); console.log(' 1. 查看 SQL_ANALYSIS.md 了解详细信息'); console.log(' 2. 手动执行缺失的ALTER TABLE语句'); console.log(' 3. 或者重新执行数据库迁移\n'); process.exit(1); } ================================================ FILE: tmarks/scripts/check-migrations.js ================================================ #!/usr/bin/env node /** * 检查是否有待执行的迁移 * 在 pnpm install 后自动运行 */ import { readFileSync, existsSync, readdirSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const MIGRATIONS_DIR = join(__dirname, '../migrations') const MIGRATION_HISTORY_FILE = join(__dirname, '../.migration-history.json') // 颜色输出 const colors = { reset: '\x1b[0m', yellow: '\x1b[33m', blue: '\x1b[34m', gray: '\x1b[90m', } function log(message, color = 'reset') { console.log(`${colors[color]}${message}${colors.reset}`) } // 读取迁移历史 function getMigrationHistory() { if (!existsSync(MIGRATION_HISTORY_FILE)) { return { migrations: [] } } try { return JSON.parse(readFileSync(MIGRATION_HISTORY_FILE, 'utf-8')) } catch (error) { return { migrations: [] } } } // 获取所有迁移文件 function getMigrationFiles() { if (!existsSync(MIGRATIONS_DIR)) { return [] } return readdirSync(MIGRATIONS_DIR) .filter(file => /^\d{4}_.*\.sql$/.test(file)) .sort() } // 主函数 function main() { const history = getMigrationHistory() const appliedMigrations = new Set(history.migrations || []) const migrationFiles = getMigrationFiles() if (migrationFiles.length === 0) { return } const pendingMigrations = migrationFiles.filter(file => !appliedMigrations.has(file)) if (pendingMigrations.length > 0) { log('\n' + '='.repeat(60), 'yellow') log('⚠️ 检测到待执行的数据库迁移', 'yellow') log('='.repeat(60), 'yellow') log('', 'reset') log(`发现 ${pendingMigrations.length} 个新的迁移文件:`, 'blue') log('', 'reset') pendingMigrations.forEach(file => { log(` • ${file}`, 'gray') }) log('', 'reset') log('请执行以下命令应用迁移:', 'blue') log('', 'reset') log(' 本地开发环境:', 'gray') log(' pnpm db:auto-migrate:local', 'yellow') log('', 'reset') log(' 生产环境:', 'gray') log(' pnpm db:auto-migrate', 'yellow') log('', 'reset') log('='.repeat(60), 'yellow') log('', 'reset') } } // 运行 try { main() } catch (error) { // 静默失败,不影响 install } ================================================ FILE: tmarks/scripts/prepare-deploy.js ================================================ #!/usr/bin/env node /** * 准备Cloudflare Pages部署 * 将dist内容和functions目录合并到同一层级 */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const distDir = path.join(__dirname, '../dist'); const functionsDir = path.join(__dirname, '../functions'); const deployDir = path.join(__dirname, '../.deploy'); console.log('🚀 准备Cloudflare Pages部署...'); // 清理旧的部署目录(尝试删除,失败则跳过) if (fs.existsSync(deployDir)) { try { fs.rmSync(deployDir, { recursive: true, force: true }); console.log('✓ 清理旧部署目录'); } catch (error) { console.log('⚠ 无法删除旧目录,将覆盖文件'); } } // 创建部署目录 fs.mkdirSync(deployDir, { recursive: true }); // 复制dist内容到部署目录 console.log('📦 复制静态文件...'); copyDir(distDir, deployDir); // 复制functions目录到部署目录 console.log('⚡ 复制Functions...'); const targetFunctionsDir = path.join(deployDir, 'functions'); copyDir(functionsDir, targetFunctionsDir); console.log('✅ 部署准备完成!'); console.log(`� 部署目录库: ${deployDir}`); /** * 递归复制目录 */ function copyDir(src, dest) { if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); } const entries = fs.readdirSync(src, { withFileTypes: true }); for (const entry of entries) { // 跳过废弃的备份目录 if (entry.name.startsWith('_deprecated')) { console.log(`⏭ 跳过废弃目录: ${entry.name}`); continue; } const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDir(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } ================================================ FILE: tmarks/shared/import-export-types.ts ================================================ /** * 导入导出功能的共享类型定义 * 提供类型安全的接口定义,支持多种格式的数据交换 */ // ============ 基础数据类型 ============ export interface ExportBookmark { id: string title: string url: string description?: string | null cover_image?: string | null cover_image_id?: string | null favicon?: string | null tags: string[] is_pinned: boolean is_archived?: boolean is_public?: boolean created_at: string updated_at: string click_count?: number last_clicked_at?: string | null has_snapshot?: boolean latest_snapshot_at?: string | null snapshot_count?: number deleted_at?: string | null } export interface ExportTag { id: string name: string color: string click_count?: number last_clicked_at?: string | null created_at: string updated_at: string deleted_at?: string | null bookmark_count?: number } export interface ExportUser { id: string email: string name?: string created_at: string } export interface ExportTabGroupItem { id: string title: string url: string favicon?: string position: number is_pinned: boolean is_todo: boolean is_archived: boolean created_at: string } export interface ExportTabGroup { id: string title: string parent_id?: string is_folder: boolean position: number color?: string tags?: string[] is_deleted?: boolean deleted_at?: string created_at: string updated_at: string items: ExportTabGroupItem[] } // ============ 导出格式 ============ export type ExportFormat = 'json' export interface TMarksExportData { version: string format: 'tmarks' // TMarks 专属标识 exported_at: string user: ExportUser bookmarks: ExportBookmark[] tags: ExportTag[] tab_groups?: ExportTabGroup[] metadata: { total_bookmarks: number total_tags: number total_tab_groups?: number export_format: ExportFormat source: 'tmarks' // 明确标识来源 } } // ============ 导入格式(用于浏览器扩展)============ export type ImportFormat = 'html' | 'json' | 'tmarks' export interface ParsedBookmark { title: string url: string description?: string cover_image?: string tags: string[] created_at?: string folder?: string } export interface ParsedTag { name: string color?: string } export interface ParsedTabGroupItem { id?: string title: string url: string favicon?: string position: number is_pinned: boolean is_todo: boolean is_archived: boolean created_at?: string } export interface ParsedTabGroup { id?: string title: string parent_id?: string is_folder: boolean position: number color?: string tags?: string created_at?: string updated_at?: string items: ParsedTabGroupItem[] } export interface ImportData { bookmarks: ParsedBookmark[] tags: ParsedTag[] tab_groups?: ParsedTabGroup[] metadata?: { source: ImportFormat total_items: number total_tab_groups?: number parsed_at: string } } // ============ 操作结果 ============ export interface ImportResult { success: number failed: number skipped: number total: number errors: ImportError[] created_bookmarks: string[] created_tags: string[] created_tab_groups: string[] tab_groups_success: number tab_groups_failed: number } export interface ExportResult { success: boolean format: ExportFormat filename: string size: number exported_at: string error?: string } export interface ImportError { index: number item: ParsedBookmark error: string code: ImportErrorCode } export type ImportErrorCode = | 'INVALID_URL' | 'DUPLICATE_URL' | 'MISSING_TITLE' | 'TAG_CREATION_FAILED' | 'BOOKMARK_CREATION_FAILED' | 'VALIDATION_ERROR' | 'UNKNOWN_ERROR' // ============ 进度跟踪 ============ export interface ProgressInfo { current: number total: number percentage: number status: ProgressStatus message: string estimated_remaining?: number } export type ProgressStatus = | 'preparing' | 'parsing' | 'validating' | 'importing' | 'exporting' | 'completed' | 'failed' | 'cancelled' // ============ 配置选项 ============ export interface ImportOptions { skip_duplicates: boolean create_missing_tags: boolean preserve_timestamps: boolean batch_size: number max_concurrent: number default_tag_color: string folder_as_tag: boolean } export interface ExportOptions { include_tags: boolean include_metadata: boolean format_options: { pretty_print?: boolean include_click_stats?: boolean include_user_info?: boolean } } // ============ API 请求/响应 ============ export interface ExportRequest { format: ExportFormat options?: ExportOptions } export interface ImportRequest { format: ImportFormat data: string | File options?: ImportOptions } export interface ImportProgressResponse { progress: ProgressInfo result?: ImportResult } export interface ExportProgressResponse { progress: ProgressInfo result?: ExportResult } // ============ 解析器接口 ============ export interface ImportParser { format: ImportFormat parse(content: string): Promise validate(data: ImportData): Promise } export interface ValidationResult { valid: boolean errors: ValidationError[] warnings: ValidationWarning[] } export interface ValidationError { field: string message: string value?: unknown } export interface ValidationWarning { field: string message: string value?: unknown } // ============ 导出器接口 ============ export interface Exporter { format: ExportFormat export(data: TMarksExportData, options?: ExportOptions): Promise } export interface ExportOutput { content: string | Buffer filename: string mimeType: string size: number } // ============ 工具函数类型 ============ export type DateParser = (dateString: string) => Date | null export type URLValidator = (url: string) => boolean export type TagNormalizer = (tagName: string) => string // ============ 常量 ============ export const SUPPORTED_IMPORT_FORMATS: ImportFormat[] = ['html', 'json', 'tmarks'] export const SUPPORTED_EXPORT_FORMATS: ExportFormat[] = ['json'] export const DEFAULT_IMPORT_OPTIONS: ImportOptions = { skip_duplicates: true, create_missing_tags: true, preserve_timestamps: true, batch_size: 50, max_concurrent: 5, default_tag_color: '#3b82f6', folder_as_tag: true } export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { include_tags: true, include_metadata: true, format_options: { pretty_print: true, include_click_stats: false, include_user_info: false } } export const EXPORT_VERSION = '1.0.0' ================================================ FILE: tmarks/shared/permissions.ts ================================================ /** * 权限系统 - 前后端共享 * 定义所有 API Key 权限常量和工具函数 */ /** * 权限常量 */ export const PERMISSIONS = { // 书签权限 BOOKMARKS_CREATE: 'bookmarks.create', BOOKMARKS_READ: 'bookmarks.read', BOOKMARKS_UPDATE: 'bookmarks.update', BOOKMARKS_DELETE: 'bookmarks.delete', BOOKMARKS_ALL: 'bookmarks.*', // 标签权限 TAGS_CREATE: 'tags.create', TAGS_READ: 'tags.read', TAGS_UPDATE: 'tags.update', TAGS_DELETE: 'tags.delete', TAGS_ASSIGN: 'tags.assign', TAGS_ALL: 'tags.*', // 收纳(标签页组)权限 TAB_GROUPS_CREATE: 'tab_groups.create', TAB_GROUPS_READ: 'tab_groups.read', TAB_GROUPS_UPDATE: 'tab_groups.update', TAB_GROUPS_DELETE: 'tab_groups.delete', TAB_GROUPS_ALL: 'tab_groups.*', // AI 权限 AI_SUGGEST: 'ai.suggest', // 用户权限 USER_READ: 'user.read', USER_PREFERENCES_READ: 'user.preferences.read', } as const export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS] /** * 权限模板 - 使用 i18n key */ export const PERMISSION_TEMPLATES = { READ_ONLY: { nameKey: 'settings:permissions.templates.readOnly', descriptionKey: 'settings:permissions.templates.readOnlyDesc', permissions: [ PERMISSIONS.BOOKMARKS_READ, PERMISSIONS.TAGS_READ, PERMISSIONS.USER_READ, ] as string[], }, BASIC: { nameKey: 'settings:permissions.templates.basic', descriptionKey: 'settings:permissions.templates.basicDesc', permissions: [ PERMISSIONS.BOOKMARKS_CREATE, PERMISSIONS.BOOKMARKS_READ, PERMISSIONS.TAGS_CREATE, PERMISSIONS.TAGS_READ, PERMISSIONS.TAGS_ASSIGN, PERMISSIONS.USER_READ, ] as string[], }, FULL: { nameKey: 'settings:permissions.templates.full', descriptionKey: 'settings:permissions.templates.fullDesc', permissions: [ PERMISSIONS.BOOKMARKS_ALL, PERMISSIONS.TAGS_ALL, PERMISSIONS.TAB_GROUPS_ALL, PERMISSIONS.AI_SUGGEST, PERMISSIONS.USER_READ, ] as string[], }, } as const export type PermissionTemplate = keyof typeof PERMISSION_TEMPLATES /** * 检查是否有权限 * @param userPermissions 用户拥有的权限列表 * @param requiredPermission 需要的权限 * @returns 是否有权限 */ export function hasPermission( userPermissions: string[], requiredPermission: string ): boolean { return userPermissions.some(p => { // 完全匹配 if (p === requiredPermission) return true // 通配符匹配:bookmarks.* 匹配 bookmarks.create if (p.endsWith('.*')) { const prefix = p.slice(0, -2) return requiredPermission.startsWith(prefix + '.') } return false }) } /** * 权限到 i18n key 的映射 */ const PERMISSION_I18N_KEYS: Record = { 'bookmarks.create': 'settings:permissions.bookmarksCreate', 'bookmarks.read': 'settings:permissions.bookmarksRead', 'bookmarks.update': 'settings:permissions.bookmarksUpdate', 'bookmarks.delete': 'settings:permissions.bookmarksDelete', 'bookmarks.*': 'settings:permissions.bookmarksAll', 'tags.create': 'settings:permissions.tagsCreate', 'tags.read': 'settings:permissions.tagsRead', 'tags.update': 'settings:permissions.tagsUpdate', 'tags.delete': 'settings:permissions.tagsDelete', 'tags.assign': 'settings:permissions.tagsAssign', 'tags.*': 'settings:permissions.tagsAll', 'tab_groups.create': 'settings:permissions.tabGroupsCreate', 'tab_groups.read': 'settings:permissions.tabGroupsRead', 'tab_groups.update': 'settings:permissions.tabGroupsUpdate', 'tab_groups.delete': 'settings:permissions.tabGroupsDelete', 'tab_groups.*': 'settings:permissions.tabGroupsAll', 'ai.suggest': 'settings:permissions.aiSuggest', 'user.read': 'settings:permissions.userRead', 'user.preferences.read': 'settings:permissions.userPreferencesRead', } /** * 获取权限的 i18n key * @param permission 权限字符串 * @returns i18n key */ export function getPermissionI18nKey(permission: string): string { return PERMISSION_I18N_KEYS[permission] || permission } /** * 获取权限的显示名称(需要传入翻译函数) * @param permission 权限字符串 * @param t 翻译函数 * @returns 显示名称 */ export function getPermissionLabel(permission: string, t?: (key: string) => string): string { const key = PERMISSION_I18N_KEYS[permission] if (t && key) { return t(key) } // 后备:返回权限字符串本身 return permission } /** * 权限分组的 i18n key */ const PERMISSION_GROUP_I18N_KEYS = { bookmarks: 'settings:permissions.bookmarks', tags: 'settings:permissions.tags', tabGroups: 'settings:permissions.tabGroups', other: 'settings:permissions.other', } /** * 获取权限的分组(需要传入翻译函数) */ export function getPermissionGroups(t?: (key: string) => string): Array<{ name: string nameKey: string permissions: Array<{ value: string; label: string; labelKey: string }> }> { const getName = (key: string) => t ? t(key) : key const getLabel = (permission: string) => { const labelKey = getPermissionI18nKey(permission) return t ? t(labelKey) : permission } return [ { name: getName(PERMISSION_GROUP_I18N_KEYS.bookmarks), nameKey: PERMISSION_GROUP_I18N_KEYS.bookmarks, permissions: [ { value: PERMISSIONS.BOOKMARKS_CREATE, label: getLabel(PERMISSIONS.BOOKMARKS_CREATE), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_CREATE) }, { value: PERMISSIONS.BOOKMARKS_READ, label: getLabel(PERMISSIONS.BOOKMARKS_READ), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_READ) }, { value: PERMISSIONS.BOOKMARKS_UPDATE, label: getLabel(PERMISSIONS.BOOKMARKS_UPDATE), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_UPDATE) }, { value: PERMISSIONS.BOOKMARKS_DELETE, label: getLabel(PERMISSIONS.BOOKMARKS_DELETE), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_DELETE) }, ], }, { name: getName(PERMISSION_GROUP_I18N_KEYS.tags), nameKey: PERMISSION_GROUP_I18N_KEYS.tags, permissions: [ { value: PERMISSIONS.TAGS_CREATE, label: getLabel(PERMISSIONS.TAGS_CREATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_CREATE) }, { value: PERMISSIONS.TAGS_READ, label: getLabel(PERMISSIONS.TAGS_READ), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_READ) }, { value: PERMISSIONS.TAGS_UPDATE, label: getLabel(PERMISSIONS.TAGS_UPDATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_UPDATE) }, { value: PERMISSIONS.TAGS_DELETE, label: getLabel(PERMISSIONS.TAGS_DELETE), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_DELETE) }, { value: PERMISSIONS.TAGS_ASSIGN, label: getLabel(PERMISSIONS.TAGS_ASSIGN), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_ASSIGN) }, ], }, { name: getName(PERMISSION_GROUP_I18N_KEYS.tabGroups), nameKey: PERMISSION_GROUP_I18N_KEYS.tabGroups, permissions: [ { value: PERMISSIONS.TAB_GROUPS_CREATE, label: getLabel(PERMISSIONS.TAB_GROUPS_CREATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_CREATE) }, { value: PERMISSIONS.TAB_GROUPS_READ, label: getLabel(PERMISSIONS.TAB_GROUPS_READ), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_READ) }, { value: PERMISSIONS.TAB_GROUPS_UPDATE, label: getLabel(PERMISSIONS.TAB_GROUPS_UPDATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_UPDATE) }, { value: PERMISSIONS.TAB_GROUPS_DELETE, label: getLabel(PERMISSIONS.TAB_GROUPS_DELETE), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_DELETE) }, ], }, { name: getName(PERMISSION_GROUP_I18N_KEYS.other), nameKey: PERMISSION_GROUP_I18N_KEYS.other, permissions: [ { value: PERMISSIONS.AI_SUGGEST, label: getLabel(PERMISSIONS.AI_SUGGEST), labelKey: getPermissionI18nKey(PERMISSIONS.AI_SUGGEST) }, { value: PERMISSIONS.USER_READ, label: getLabel(PERMISSIONS.USER_READ), labelKey: getPermissionI18nKey(PERMISSIONS.USER_READ) }, ], }, ] } ================================================ FILE: tmarks/src/App.tsx ================================================ import { QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter } from 'react-router-dom' import { useEffect, useRef } from 'react' import { AppRouter } from '@/routes' import { useAuthStore } from '@/stores/authStore' import { ToastContainer } from '@/components/common/Toast' import { DialogHost } from '@/components/common/DialogHost' import { useToastStore } from '@/stores/toastStore' import { useThemeStore } from '@/stores/themeStore' import { queryClient } from '@/lib/query-client' import { ErrorBoundary } from '@/components/common/ErrorBoundary' function App() { const { user, isAuthenticated, accessToken, refreshToken, clearAuth, refreshAccessToken } = useAuthStore() const { toasts, removeToast } = useToastStore() const { theme, colorTheme } = useThemeStore() const previousUserId = useRef(undefined) // 应用主题到 document.documentElement useEffect(() => { const root = document.documentElement root.setAttribute('data-theme', theme) root.setAttribute('data-color-theme', colorTheme) }, [theme, colorTheme]) useEffect(() => { if (isAuthenticated && !accessToken) { if (refreshToken) { refreshAccessToken().catch((err) => { console.error('Failed to refresh access token:', err) clearAuth() }) } else { clearAuth() } } }, [isAuthenticated, accessToken, refreshToken, refreshAccessToken, clearAuth]) const userId = user?.id ?? null useEffect(() => { if (previousUserId.current === undefined) { previousUserId.current = userId return } if (previousUserId.current !== userId) { queryClient.clear() previousUserId.current = userId } }, [userId]) useEffect(() => { let debounceTimer: ReturnType const handler = () => { clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['bookmarks'] }).catch((err) => console.error('Failed to invalidate bookmarks:', err)) queryClient.invalidateQueries({ queryKey: ['tags'] }).catch((err) => console.error('Failed to invalidate tags:', err)) }, 300) } window.addEventListener('tmarks:data-changed', handler) return () => { window.removeEventListener('tmarks:data-changed', handler) clearTimeout(debounceTimer) } }, []) return ( ) } export default App ================================================ FILE: tmarks/src/components/api-keys/ApiKeyCard.tsx ================================================ /** * API Key 卡片组件 * 显示单个 API Key 的摘要信息 */ import { useTranslation } from 'react-i18next' import type { ApiKey } from '@/services/api-keys' import { formatDistanceToNow } from 'date-fns' import { zhCN, enUS } from 'date-fns/locale' interface ApiKeyCardProps { apiKey: ApiKey onViewDetails: () => void onRevoke: () => void onDelete?: () => void } export function ApiKeyCard({ apiKey, onViewDetails, onRevoke, onDelete }: ApiKeyCardProps) { const { t, i18n } = useTranslation('settings') const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS const statusIcon = { active: , revoked: , expired: , }[apiKey.status] const statusText = t(`apiKey.status.${apiKey.status}`) const lastUsedText = apiKey.last_used_at ? formatDistanceToNow(new Date(apiKey.last_used_at), { addSuffix: true, locale: dateLocale, }) : t('apiKey.neverUsed') return (
{/* 名称和前缀 */}

{apiKey.name}

{apiKey.key_prefix}...
{/* 描述 */} {apiKey.description && (

{apiKey.description}

)} {/* 元信息 */}
{t('apiKey.status.label')}: {statusIcon} {statusText}
{t('apiKey.permissions')}: {t('apiKey.permissionsCount', { count: apiKey.permissions.length })}
{t('apiKey.lastUsed')}: {lastUsedText}
{t('apiKey.createdAt')}:{' '} {new Date(apiKey.created_at).toLocaleDateString(i18n.language)}
{apiKey.expires_at && (
{t('apiKey.expiresAt')}:{' '} {new Date(apiKey.expires_at).toLocaleDateString(i18n.language)}
)}
{/* 操作按钮 */}
{apiKey.status === 'active' && ( )} {onDelete && ( )}
) } ================================================ FILE: tmarks/src/components/api-keys/ApiKeyDetailModal.tsx ================================================ /** * API Key 详情模态框 * 显示 API Key 的详细信息和使用日志 */ import { useTranslation } from 'react-i18next' import { useApiKey, useApiKeyLogs } from '@/hooks/useApiKeys' import { getPermissionLabel } from '@shared/permissions' import type { ApiKey } from '@/services/api-keys' import { formatDistanceToNow } from 'date-fns' import { zhCN, enUS } from 'date-fns/locale' import { Z_INDEX } from '@/lib/constants/z-index' interface ApiKeyDetailModalProps { apiKey: ApiKey onClose: () => void } export function ApiKeyDetailModal({ apiKey, onClose }: ApiKeyDetailModalProps) { const { t, i18n } = useTranslation('settings') const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS const { data: keyData } = useApiKey(apiKey.id) const { data: logsData } = useApiKeyLogs(apiKey.id, 10) const key = keyData || apiKey const logs = logsData?.logs || [] const stats = keyData?.stats const statusIcon = { active: '🟢', revoked: '🔴', expired: '🟠', }[key.status] const statusText = t(`apiKey.status.${key.status}`) return (
{/* 标题 */}

{key.name}

{/* 基本信息 */}

{t('apiKey.detail.basicInfo')}

{t('apiKey.detail.keyPrefix')} {key.key_prefix}...
{t('apiKey.status.label')}: {statusIcon} {statusText}
{t('apiKey.detail.createdAt')} {new Date(key.created_at).toLocaleString(i18n.language)}
{key.expires_at && (
{t('apiKey.detail.expiresAt')} {new Date(key.expires_at).toLocaleString(i18n.language)}
)} {!key.expires_at && (
{t('apiKey.detail.expiresAt')} {t('apiKey.detail.neverExpire')}
)} {key.description && (
{t('apiKey.detail.description')} {key.description}
)}
{/* 权限列表 */}

{t('apiKey.detail.permissions')}

{key.permissions.map((perm) => (
{getPermissionLabel(perm)} ({perm})
))}
{/* 使用情况 */} {stats && (

{t('apiKey.detail.usage')}

{t('apiKey.detail.lastUsed')} {stats.last_used_at ? formatDistanceToNow(new Date(stats.last_used_at), { addSuffix: true, locale: dateLocale, }) : t('apiKey.detail.neverUsed')}
{t('apiKey.detail.totalRequests')} {t('apiKey.detail.requestCount', { count: stats.total_requests })}
{stats.last_used_ip && (
{t('apiKey.detail.lastIp')} {stats.last_used_ip}
)}
)} {/* 最近活动 */} {logs.length > 0 && (

{t('apiKey.detail.recentActivity')} {t('apiKey.detail.recentActivityLimit', { count: 10 })}

{logs.map((log, index) => ( ))}
{t('apiKey.detail.tableTime')} {t('apiKey.detail.tableMethod')} {t('apiKey.detail.tableEndpoint')} {t('apiKey.detail.tableStatus')}
{new Date(log.created_at).toLocaleString(i18n.language, { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', })} {log.method} {log.endpoint} {log.status}
)} {logs.length === 0 && (
{t('apiKey.detail.noLogs')}
)}
) } ================================================ FILE: tmarks/src/components/api-keys/CreateApiKeyModal.tsx ================================================ /** * 创建 API Key 模态框 * 多步骤流程:基本信息 → 权限设置 → 过期设置 → 显示 Key */ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useCreateApiKey } from '@/hooks/useApiKeys' import { AlertDialog } from '@/components/common/AlertDialog' import { PERMISSION_TEMPLATES } from '@shared/permissions' import type { ApiKeyWithKey, CreateApiKeyRequest } from '@/services/api-keys' import { Z_INDEX } from '@/lib/constants/z-index' import { StepBasicInfo } from './StepBasicInfo' import { StepPermissions } from './StepPermissions' import { StepSuccess } from './StepSuccess' interface CreateApiKeyModalProps { onClose: () => void } type Step = 'basic' | 'permissions' | 'expiration' | 'success' export function CreateApiKeyModal({ onClose }: CreateApiKeyModalProps) { const { t } = useTranslation('settings') const createApiKey = useCreateApiKey() const [step, setStep] = useState('basic') const [formData, setFormData] = useState({ name: '', description: '', template: 'BASIC', permissions: [], expires_at: null, }) const [createdKey, setCreatedKey] = useState(null) const [showErrorAlert, setShowErrorAlert] = useState(false) const updateFormData = (data: Partial) => { setFormData((prev) => ({ ...prev, ...data })) } const handleNext = () => { if (step === 'basic') setStep('permissions') else if (step === 'permissions') setStep('expiration') else if (step === 'expiration') handleCreate() } const handleBack = () => { if (step === 'permissions') setStep('basic') else if (step === 'expiration') setStep('permissions') } const handleCreate = async () => { try { const result = await createApiKey.mutateAsync(formData) setCreatedKey(result) setStep('success') } catch { setShowErrorAlert(true) } } const canProceed = () => { if (step === 'basic') return formData.name.trim().length > 0 if (step === 'permissions') { const perms = formData.template ? PERMISSION_TEMPLATES[formData.template].permissions : formData.permissions return !!(perms && perms.length > 0) } return true } return (
setShowErrorAlert(false)} />
e.stopPropagation()}> {/* 步骤 1: 基本信息 */} {step === 'basic' && ( )} {/* 步骤 2: 权限设置 */} {step === 'permissions' && ( )} {/* 步骤 3: 过期设置 */} {step === 'expiration' && (

{t('apiKey.create.title')} - {t('apiKey.create.step', { current: 3, total: 4 })}

)} {/* 步骤 4: 成功显示 Key */} {step === 'success' && createdKey && ( )}
) } ================================================ FILE: tmarks/src/components/api-keys/StepBasicInfo.tsx ================================================ import { useTranslation } from 'react-i18next' import type { CreateApiKeyRequest } from '@/services/api-keys' interface StepBasicInfoProps { formData: CreateApiKeyRequest onChange: (data: Partial) => void onNext: () => void onCancel: () => void canProceed: boolean } export function StepBasicInfo({ formData, onChange, onNext, onCancel, canProceed, }: StepBasicInfoProps) { const { t } = useTranslation('settings') return (

{t('apiKey.create.title')} - {t('apiKey.create.step', { current: 1, total: 3 })}

onChange({ name: e.target.value })} />

{t('apiKey.create.nameHint')}