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 驱动的智能书签管理系统**
[](https://www.typescriptlang.org/)
[](https://reactjs.org/)
[](https://vitejs.dev/)
[](https://workers.cloudflare.com/)
[](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