diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..39721b9 --- /dev/null +++ b/REFACTOR_PLAN.md @@ -0,0 +1,236 @@ +# VisitorMode 重构计划 + +## 概述 +`visitorMode` 设置项已被标记为 deprecated。权限控制应完全基于 `userRole`('admin' | 'visitor'),而不是依赖 settings 中的 `visitorMode` 标志。 + +## 当前架构分析 + +### 当前逻辑 +1. **Settings 层面**: + - `visitorMode: boolean` - 控制是否启用访客模式 + - `visitorPassword: string` - 访客账户密码 + +2. **权限控制逻辑**: + - 如果 `visitorMode === true` 且 `userRole !== 'admin'` → 限制为只读 + - 两个 middleware 都检查 `visitorMode` 设置 + +3. **登录逻辑**: + - 如果 `visitorMode === true` 且密码匹配 `visitorPassword` → 分配 `visitor` role + +### 问题 +- 权限控制混合了 settings 配置和 user role +- `visitorMode` 设置项是多余的,权限应该完全基于 `userRole` +- 代码复杂度增加,需要同时检查两个条件 + +## 重构目标 + +### 新架构 +1. **权限控制**:完全基于 `userRole` + - `userRole === 'visitor'` → 只读权限 + - `userRole === 'admin'` → 完全权限 + - `userRole === null` → 未登录(根据 loginEnabled 决定是否允许访问) + +2. **登录逻辑**: + - 密码匹配 `password` → `admin` role + - 密码匹配 `visitorPassword` → `visitor` role(不再检查 `visitorMode`) + - Passkey → `admin` role(保持不变) + +3. **Settings**: + - 移除 `visitorMode` 设置项(deprecated,但保留字段以兼容旧数据) + - 保留 `visitorPassword`(用于登录获取 visitor role) + +## 重构步骤 + +### Phase 1: Backend 重构 + +#### 1.1 创建基于 Role 的权限中间件 +**文件**: `backend/src/middleware/roleBasedAuthMiddleware.ts` (新建) +- 替换 `visitorModeMiddleware.ts` +- 逻辑:如果 `userRole === 'visitor'`,限制为只读 +- 允许 admin 和未登录用户(根据 loginEnabled)的 GET 请求 +- 阻止 visitor 的写操作(POST, PUT, DELETE, PATCH) + +#### 1.2 更新 Settings 中间件 +**文件**: `backend/src/middleware/visitorModeSettingsMiddleware.ts` → 重命名为 `roleBasedSettingsMiddleware.ts` +- 移除对 `visitorMode` 设置的检查 +- 基于 `userRole === 'visitor'` 进行权限控制 +- 允许 visitor 更新 CloudFlare 设置(如果需要) + +#### 1.3 更新密码验证逻辑 +**文件**: `backend/src/services/passwordService.ts` +- 移除 `visitorMode` 检查(第111行) +- 如果密码匹配 `visitorPassword`,直接分配 `visitor` role +- 逻辑:`if (mergedSettings.visitorPassword && isVisitorMatch) → visitor role` + +#### 1.4 更新 Settings 验证服务 +**文件**: `backend/src/services/settingsValidationService.ts` +- 移除 `checkVisitorModeRestrictions` 函数(或重构为基于 role 的检查) +- 移除 `visitorMode` 相关的验证逻辑 + +#### 1.5 更新 Settings Controller +**文件**: `backend/src/controllers/settingsController.ts` +- 移除 `visitorMode` 相关的检查逻辑(第132-160行) +- 基于 `userRole === 'visitor'` 进行权限控制 + +#### 1.6 更新 Settings 类型 +**文件**: `backend/src/types/settings.ts` +- 标记 `visitorMode` 为 deprecated(使用 JSDoc 注释) +- 保留字段以兼容旧数据,但不使用 + +#### 1.7 更新 Server 配置 +**文件**: `backend/src/server.ts` +- 替换 `visitorModeMiddleware` → `roleBasedAuthMiddleware` +- 替换 `visitorModeSettingsMiddleware` → `roleBasedSettingsMiddleware` + +### Phase 2: Frontend 重构 + +#### 2.1 重构 VisitorModeContext +**文件**: `frontend/src/contexts/VisitorModeContext.tsx` +- 重命名为 `RoleBasedAuthContext.tsx` 或直接移除 +- 逻辑改为:`visitorMode = userRole === 'visitor'` +- 不再依赖 settings 中的 `visitorMode` + +#### 2.2 更新所有使用 VisitorMode 的组件 +需要更新的文件: +- `frontend/src/components/Settings/SecuritySettings.tsx` + - 移除 `visitorMode` switch + - 保留 `visitorPassword` 输入(用于设置访客密码) + - 更新说明文字:访客密码用于登录获取 visitor role + +- `frontend/src/components/Header/*.tsx` + - 使用 `userRole === 'visitor'` 替代 `visitorMode` + +- `frontend/src/components/ManagePage/*.tsx` + - 使用 `userRole === 'visitor'` 替代 `visitorMode` + +- `frontend/src/components/VideoPlayer/*.tsx` + - 使用 `userRole === 'visitor'` 替代 `visitorMode` + +- `frontend/src/pages/*.tsx` + - 使用 `userRole === 'visitor'` 替代 `visitorMode` + +#### 2.3 更新 LoginPage +**文件**: `frontend/src/pages/LoginPage.tsx` +- 移除对 `visitorMode` 的检查 +- Visitor tab 的显示逻辑改为:如果 `visitorPassword` 已设置,显示 Visitor tab +- 或者:始终显示 Visitor tab(如果设置了 visitorPassword) + +#### 2.4 更新类型定义 +**文件**: `frontend/src/types.ts` +- 移除 `visitorMode` 字段(或标记为 deprecated) +- 保留 `visitorPassword` 相关字段 + +### Phase 3: 测试更新 + +#### 3.1 Backend 测试 +- `backend/src/__tests__/middleware/visitorModeMiddleware.test.ts` + - 重命名为 `roleBasedAuthMiddleware.test.ts` + - 更新测试用例:基于 `userRole` 而非 `visitorMode` 设置 + +- `backend/src/__tests__/middleware/visitorModeSettingsMiddleware.test.ts` + - 重命名为 `roleBasedSettingsMiddleware.test.ts` + - 更新测试用例 + +- `backend/src/__tests__/services/passwordService.test.ts` + - 更新测试:移除 `visitorMode` 检查 + +#### 3.2 Frontend 测试 +- 更新所有 mock `useVisitorMode` 的测试 +- 改为 mock `useAuth` 并返回 `userRole: 'visitor'` + +### Phase 4: 文档更新 + +#### 4.1 API 文档 +- `documents/en/api-endpoints.md` +- `documents/zh/api-endpoints.md` +- 移除 `visitorMode` 相关说明 + +#### 4.2 代码注释 +- 更新所有相关代码注释 +- 添加 migration notes + +## 迁移策略 + +### 数据迁移 +1. **向后兼容**: + - 保留 `visitorMode` 字段在 Settings 类型中(标记为 deprecated) + - 不强制删除现有数据中的 `visitorMode` 字段 + +2. **自动迁移**(可选): + - 在启动时检查:如果 `visitorMode === true` 且 `visitorPassword` 已设置,记录警告 + - 提示用户:`visitorMode` 已废弃,权限现在基于 user role + +### 渐进式迁移 +1. **Phase 1**: 先完成 Backend 重构,确保 API 兼容 +2. **Phase 2**: 再完成 Frontend 重构 +3. **Phase 3**: 更新测试 +4. **Phase 4**: 更新文档 + +## 风险评估 + +### 高风险 +- **登录逻辑变更**:需要确保 visitor 登录仍然工作 +- **权限控制变更**:需要确保 visitor 权限正确限制 + +### 中风险 +- **前端组件更新**:大量组件需要更新 +- **测试覆盖**:需要更新大量测试用例 + +### 低风险 +- **文档更新**:纯文档工作 + +## 回滚计划 + +如果出现问题: +1. 保留旧代码在 git 分支中 +2. 可以临时恢复 `visitorMode` 检查逻辑 +3. 添加 feature flag 控制新旧逻辑切换(如果需要) + +## 验收标准 + +1. ✅ Visitor 用户只能进行读操作(GET) +2. ✅ Visitor 用户不能进行写操作(POST, PUT, DELETE, PATCH) +3. ✅ Admin 用户拥有完全权限 +4. ✅ 使用 `visitorPassword` 登录后获得 `visitor` role +5. ✅ 使用 `password` 登录后获得 `admin` role +6. ✅ 所有测试通过 +7. ✅ 前端 UI 正确显示权限限制 +8. ✅ 不再依赖 `visitorMode` 设置项 + +## 时间估算 + +- Phase 1 (Backend): 4-6 小时 +- Phase 2 (Frontend): 6-8 小时 +- Phase 3 (Tests): 3-4 小时 +- Phase 4 (Docs): 1-2 小时 + +**总计**: 14-20 小时 + +## 文件清单 + +### Backend 需要修改的文件 +1. `backend/src/middleware/visitorModeMiddleware.ts` → 重构/删除 +2. `backend/src/middleware/visitorModeSettingsMiddleware.ts` → 重构/重命名 +3. `backend/src/services/passwordService.ts` → 修改 +4. `backend/src/services/settingsValidationService.ts` → 修改 +5. `backend/src/controllers/settingsController.ts` → 修改 +6. `backend/src/types/settings.ts` → 标记 deprecated +7. `backend/src/server.ts` → 更新中间件引用 +8. `backend/src/__tests__/middleware/visitorModeMiddleware.test.ts` → 重构 +9. `backend/src/__tests__/middleware/visitorModeSettingsMiddleware.test.ts` → 重构 + +### Frontend 需要修改的文件 +1. `frontend/src/contexts/VisitorModeContext.tsx` → 重构/删除 +2. `frontend/src/components/Settings/SecuritySettings.tsx` → 修改 +3. `frontend/src/components/Header/*.tsx` (多个文件) → 修改 +4. `frontend/src/components/ManagePage/*.tsx` (多个文件) → 修改 +5. `frontend/src/components/VideoPlayer/*.tsx` (多个文件) → 修改 +6. `frontend/src/pages/*.tsx` (多个文件) → 修改 +7. `frontend/src/types.ts` → 修改 +8. 所有相关测试文件 → 更新 + +### 文档需要更新的文件 +1. `documents/en/api-endpoints.md` +2. `documents/zh/api-endpoints.md` +3. `documents/en/directory-structure.md` +4. `documents/zh/directory-structure.md` diff --git a/backend/drizzle/0009_brief_stingray.sql b/backend/drizzle/0009_brief_stingray.sql new file mode 100644 index 0000000..6c83ea5 --- /dev/null +++ b/backend/drizzle/0009_brief_stingray.sql @@ -0,0 +1,11 @@ +CREATE TABLE `passkeys` ( + `id` text PRIMARY KEY NOT NULL, + `credential_id` text NOT NULL, + `credential_public_key` text NOT NULL, + `counter` integer DEFAULT 0 NOT NULL, + `transports` text, + `name` text, + `created_at` text NOT NULL, + `rp_id` text, + `origin` text +); diff --git a/backend/drizzle/meta/0009_snapshot.json b/backend/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..90900cf --- /dev/null +++ b/backend/drizzle/meta/0009_snapshot.json @@ -0,0 +1,907 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5627912c-5cc6-4da0-8d67-e5f73a7b4736", + "prevId": "e727cb82-6923-4f2f-a2dd-459a8a052879", + "tables": { + "collection_videos": { + "name": "collection_videos", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_id": { + "name": "video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "collection_videos_collection_id_collections_id_fk": { + "name": "collection_videos_collection_id_collections_id_fk", + "tableFrom": "collection_videos", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_videos_video_id_videos_id_fk": { + "name": "collection_videos_video_id_videos_id_fk", + "tableFrom": "collection_videos", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collection_videos_collection_id_video_id_pk": { + "columns": [ + "collection_id", + "video_id" + ], + "name": "collection_videos_collection_id_video_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collections": { + "name": "collections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "continuous_download_tasks": { + "name": "continuous_download_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_url": { + "name": "author_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "total_videos": { + "name": "total_videos", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "downloaded_count": { + "name": "downloaded_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "current_video_index": { + "name": "current_video_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "download_history": { + "name": "download_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_path": { + "name": "video_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_size": { + "name": "total_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_id": { + "name": "video_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_size": { + "name": "total_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downloaded_size": { + "name": "downloaded_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "speed": { + "name": "speed", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_public_key": { + "name": "credential_public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rp_id": { + "name": "rp_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscriptions": { + "name": "subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_url": { + "name": "author_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_video_link": { + "name": "last_video_link", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_check": { + "name": "last_check", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'YouTube'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "video_downloads": { + "name": "video_downloads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_video_id": { + "name": "source_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "video_id": { + "name": "video_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'exists'" + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "videos": { + "name": "videos", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_filename": { + "name": "video_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_filename": { + "name": "thumbnail_filename", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_path": { + "name": "video_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_parts": { + "name": "total_parts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "series_title": { + "name": "series_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size": { + "name": "file_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_played_at": { + "name": "last_played_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtitles": { + "name": "subtitles", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_url": { + "name": "channel_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 54be875..4246596 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1766776202201, "tag": "0008_useful_sharon_carter", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1767494996743, + "tag": "0009_brief_stingray", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/controllers/passwordController.ts b/backend/src/controllers/passwordController.ts index 8c4f510..9e5ac24 100644 --- a/backend/src/controllers/passwordController.ts +++ b/backend/src/controllers/passwordController.ts @@ -16,6 +16,7 @@ export const getPasswordEnabled = async ( /** * Verify password for authentication + * @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security * Errors are automatically handled by asyncHandler middleware */ export const verifyPassword = async ( @@ -47,6 +48,68 @@ export const verifyPassword = async ( } }; +/** + * Verify admin password for authentication + * Only checks admin password, not visitor password + * Errors are automatically handled by asyncHandler middleware + */ +export const verifyAdminPassword = async ( + req: Request, + res: Response +): Promise => { + const { password } = req.body; + + const result = await passwordService.verifyAdminPassword(password); + + if (result.success) { + res.json({ + success: true, + role: result.role, + token: result.token + }); + } else { + const statusCode = result.waitTime ? 429 : 401; + res.json({ + success: false, + waitTime: result.waitTime, + failedAttempts: result.failedAttempts, + message: result.message, + statusCode + }); + } +}; + +/** + * Verify visitor password for authentication + * Only checks visitor password, not admin password + * Errors are automatically handled by asyncHandler middleware + */ +export const verifyVisitorPassword = async ( + req: Request, + res: Response +): Promise => { + const { password } = req.body; + + const result = await passwordService.verifyVisitorPassword(password); + + if (result.success) { + res.json({ + success: true, + role: result.role, + token: result.token + }); + } else { + const statusCode = result.waitTime ? 429 : 401; + res.json({ + success: false, + waitTime: result.waitTime, + failedAttempts: result.failedAttempts, + message: result.message, + statusCode + }); + } +}; + /** * Get the remaining cooldown time for password reset * Errors are automatically handled by asyncHandler middleware diff --git a/backend/src/controllers/settingsController.ts b/backend/src/controllers/settingsController.ts index 8097fa6..6eba50b 100644 --- a/backend/src/controllers/settingsController.ts +++ b/backend/src/controllers/settingsController.ts @@ -124,41 +124,8 @@ export const updateSettings = async ( {} ); - // Check visitor mode restrictions (if not admin) - // If user is admin (jwt authenticated), they bypass visitor mode restrictions - const isAdmin = req.user?.role === "admin"; - - if (!isAdmin) { - const visitorModeCheck = - settingsValidationService.checkVisitorModeRestrictions( - mergedSettings, - newSettings - ); - - if (!visitorModeCheck.allowed) { - res.status(403).json({ - success: false, - error: visitorModeCheck.error, - }); - return; - } - - // Handle special case: visitorMode being set to true (already enabled) - // Only applies if NOT admin (admins can update settings while in visitor mode) - if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) { - // Only update visitorMode, ignore other changes - const allowedSettings: Settings = { - ...mergedSettings, - visitorMode: true, - }; - storageService.saveSettings(allowedSettings); - res.json({ - success: true, - settings: { ...allowedSettings, password: undefined, visitorPassword: undefined }, - }); - return; - } - } + // Permission control is now handled by roleBasedSettingsMiddleware + // No need to check visitorMode here - middleware enforces role-based access control diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 5d58913..8c5afdd 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,61 +1,71 @@ -import { relations } from 'drizzle-orm'; -import { foreignKey, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'; +import { relations } from "drizzle-orm"; +import { + foreignKey, + integer, + primaryKey, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; -export const videos = sqliteTable('videos', { - id: text('id').primaryKey(), - title: text('title').notNull(), - author: text('author'), - date: text('date'), - source: text('source'), - sourceUrl: text('source_url'), - videoFilename: text('video_filename'), - thumbnailFilename: text('thumbnail_filename'), - videoPath: text('video_path'), - thumbnailPath: text('thumbnail_path'), - thumbnailUrl: text('thumbnail_url'), - addedAt: text('added_at'), - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at'), - partNumber: integer('part_number'), - totalParts: integer('total_parts'), - seriesTitle: text('series_title'), - rating: integer('rating'), +export const videos = sqliteTable("videos", { + id: text("id").primaryKey(), + title: text("title").notNull(), + author: text("author"), + date: text("date"), + source: text("source"), + sourceUrl: text("source_url"), + videoFilename: text("video_filename"), + thumbnailFilename: text("thumbnail_filename"), + videoPath: text("video_path"), + thumbnailPath: text("thumbnail_path"), + thumbnailUrl: text("thumbnail_url"), + addedAt: text("added_at"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at"), + partNumber: integer("part_number"), + totalParts: integer("total_parts"), + seriesTitle: text("series_title"), + rating: integer("rating"), // Additional fields that might be present - description: text('description'), - viewCount: integer('view_count'), - duration: text('duration'), - tags: text('tags'), // JSON stringified array of strings - progress: integer('progress'), // Playback progress in seconds - fileSize: text('file_size'), - lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played - subtitles: text('subtitles'), // JSON stringified array of subtitle objects - channelUrl: text('channel_url'), // Author channel URL for subscriptions - visibility: integer('visibility').default(1), // 1 = visible, 0 = hidden + description: text("description"), + viewCount: integer("view_count"), + duration: text("duration"), + tags: text("tags"), // JSON stringified array of strings + progress: integer("progress"), // Playback progress in seconds + fileSize: text("file_size"), + lastPlayedAt: integer("last_played_at"), // Timestamp when video was last played + subtitles: text("subtitles"), // JSON stringified array of subtitle objects + channelUrl: text("channel_url"), // Author channel URL for subscriptions + visibility: integer("visibility").default(1), // 1 = visible, 0 = hidden }); -export const collections = sqliteTable('collections', { - id: text('id').primaryKey(), - name: text('name').notNull(), - title: text('title'), // Keeping for backward compatibility/alias - createdAt: text('created_at').notNull(), - updatedAt: text('updated_at'), +export const collections = sqliteTable("collections", { + id: text("id").primaryKey(), + name: text("name").notNull(), + title: text("title"), // Keeping for backward compatibility/alias + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at"), }); -export const collectionVideos = sqliteTable('collection_videos', { - collectionId: text('collection_id').notNull(), - videoId: text('video_id').notNull(), - order: integer('order'), // To maintain order if needed -}, (t) => ({ - pk: primaryKey({ columns: [t.collectionId, t.videoId] }), - collectionFk: foreignKey({ - columns: [t.collectionId], - foreignColumns: [collections.id], - }).onDelete('cascade'), - videoFk: foreignKey({ - columns: [t.videoId], - foreignColumns: [videos.id], - }).onDelete('cascade'), -})); +export const collectionVideos = sqliteTable( + "collection_videos", + { + collectionId: text("collection_id").notNull(), + videoId: text("video_id").notNull(), + order: integer("order"), // To maintain order if needed + }, + (t) => ({ + pk: primaryKey({ columns: [t.collectionId, t.videoId] }), + collectionFk: foreignKey({ + columns: [t.collectionId], + foreignColumns: [collections.id], + }).onDelete("cascade"), + videoFk: foreignKey({ + columns: [t.videoId], + foreignColumns: [videos.id], + }).onDelete("cascade"), + }) +); // Relations export const videosRelations = relations(videos, ({ many }) => ({ @@ -66,94 +76,100 @@ export const collectionsRelations = relations(collections, ({ many }) => ({ videos: many(collectionVideos), })); -export const collectionVideosRelations = relations(collectionVideos, ({ one }) => ({ - collection: one(collections, { - fields: [collectionVideos.collectionId], - references: [collections.id], - }), - video: one(videos, { - fields: [collectionVideos.videoId], - references: [videos.id], - }), -})); +export const collectionVideosRelations = relations( + collectionVideos, + ({ one }) => ({ + collection: one(collections, { + fields: [collectionVideos.collectionId], + references: [collections.id], + }), + video: one(videos, { + fields: [collectionVideos.videoId], + references: [videos.id], + }), + }) +); -export const settings = sqliteTable('settings', { - key: text('key').primaryKey(), - value: text('value').notNull(), // JSON stringified value +export const settings = sqliteTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), // JSON stringified value }); -export const downloads = sqliteTable('downloads', { - id: text('id').primaryKey(), - title: text('title').notNull(), - timestamp: integer('timestamp'), - filename: text('filename'), - totalSize: text('total_size'), - downloadedSize: text('downloaded_size'), - progress: integer('progress'), // Using integer for percentage (0-100) or similar - speed: text('speed'), - status: text('status').notNull().default('active'), // 'active' or 'queued' - sourceUrl: text('source_url'), - type: text('type'), +export const downloads = sqliteTable("downloads", { + id: text("id").primaryKey(), + title: text("title").notNull(), + timestamp: integer("timestamp"), + filename: text("filename"), + totalSize: text("total_size"), + downloadedSize: text("downloaded_size"), + progress: integer("progress"), // Using integer for percentage (0-100) or similar + speed: text("speed"), + status: text("status").notNull().default("active"), // 'active' or 'queued' + sourceUrl: text("source_url"), + type: text("type"), }); -export const downloadHistory = sqliteTable('download_history', { - id: text('id').primaryKey(), - title: text('title').notNull(), - author: text('author'), - sourceUrl: text('source_url'), - finishedAt: integer('finished_at').notNull(), // Timestamp - status: text('status').notNull(), // 'success', 'failed', 'skipped', or 'deleted' - error: text('error'), // Error message if failed - videoPath: text('video_path'), // Path to video file if successful - thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful - totalSize: text('total_size'), - videoId: text('video_id'), // Reference to video for skipped items - downloadedAt: integer('downloaded_at'), // Original download timestamp for deleted items - deletedAt: integer('deleted_at'), // Deletion timestamp for deleted items +export const downloadHistory = sqliteTable("download_history", { + id: text("id").primaryKey(), + title: text("title").notNull(), + author: text("author"), + sourceUrl: text("source_url"), + finishedAt: integer("finished_at").notNull(), // Timestamp + status: text("status").notNull(), // 'success', 'failed', 'skipped', or 'deleted' + error: text("error"), // Error message if failed + videoPath: text("video_path"), // Path to video file if successful + thumbnailPath: text("thumbnail_path"), // Path to thumbnail if successful + totalSize: text("total_size"), + videoId: text("video_id"), // Reference to video for skipped items + downloadedAt: integer("downloaded_at"), // Original download timestamp for deleted items + deletedAt: integer("deleted_at"), // Deletion timestamp for deleted items }); -export const subscriptions = sqliteTable('subscriptions', { - id: text('id').primaryKey(), - author: text('author').notNull(), - authorUrl: text('author_url').notNull(), - interval: integer('interval').notNull(), // Check interval in minutes - lastVideoLink: text('last_video_link'), - lastCheck: integer('last_check'), // Timestamp - downloadCount: integer('download_count').default(0), - createdAt: integer('created_at').notNull(), - platform: text('platform').default('YouTube'), +export const subscriptions = sqliteTable("subscriptions", { + id: text("id").primaryKey(), + author: text("author").notNull(), + authorUrl: text("author_url").notNull(), + interval: integer("interval").notNull(), // Check interval in minutes + lastVideoLink: text("last_video_link"), + lastCheck: integer("last_check"), // Timestamp + downloadCount: integer("download_count").default(0), + createdAt: integer("created_at").notNull(), + platform: text("platform").default("YouTube"), }); // Track downloaded video IDs to prevent re-downloading -export const videoDownloads = sqliteTable('video_downloads', { - id: text('id').primaryKey(), // Unique identifier - sourceVideoId: text('source_video_id').notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.) - sourceUrl: text('source_url').notNull(), // Original source URL - platform: text('platform').notNull(), // YouTube, Bilibili, MissAV, etc. - videoId: text('video_id'), // Reference to local video ID (null if deleted) - title: text('title'), // Video title for display - author: text('author'), // Video author - status: text('status').notNull().default('exists'), // 'exists' or 'deleted' - downloadedAt: integer('downloaded_at').notNull(), // Timestamp of first download - deletedAt: integer('deleted_at'), // Timestamp when video was deleted (nullable) +export const videoDownloads = sqliteTable("video_downloads", { + id: text("id").primaryKey(), // Unique identifier + sourceVideoId: text("source_video_id").notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.) + sourceUrl: text("source_url").notNull(), // Original source URL + platform: text("platform").notNull(), // YouTube, Bilibili, MissAV, etc. + videoId: text("video_id"), // Reference to local video ID (null if deleted) + title: text("title"), // Video title for display + author: text("author"), // Video author + status: text("status").notNull().default("exists"), // 'exists' or 'deleted' + downloadedAt: integer("downloaded_at").notNull(), // Timestamp of first download + deletedAt: integer("deleted_at"), // Timestamp when video was deleted (nullable) }); // Track continuous download tasks for downloading all previous videos from an author -export const continuousDownloadTasks = sqliteTable('continuous_download_tasks', { - id: text('id').primaryKey(), - subscriptionId: text('subscription_id'), // Reference to subscription (nullable if subscription deleted) - collectionId: text('collection_id'), // Reference to collection (nullable, for playlist tasks) - authorUrl: text('author_url').notNull(), - author: text('author').notNull(), - platform: text('platform').notNull(), // YouTube, Bilibili, etc. - status: text('status').notNull().default('active'), // 'active', 'paused', 'completed', 'cancelled' - totalVideos: integer('total_videos').default(0), // Total videos found - downloadedCount: integer('downloaded_count').default(0), // Number of videos downloaded - skippedCount: integer('skipped_count').default(0), // Number of videos skipped (already downloaded) - failedCount: integer('failed_count').default(0), // Number of videos that failed - currentVideoIndex: integer('current_video_index').default(0), // Current video being processed - createdAt: integer('created_at').notNull(), // Timestamp when task was created - updatedAt: integer('updated_at'), // Timestamp of last update - completedAt: integer('completed_at'), // Timestamp when task completed - error: text('error'), // Error message if task failed -}); +export const continuousDownloadTasks = sqliteTable( + "continuous_download_tasks", + { + id: text("id").primaryKey(), + subscriptionId: text("subscription_id"), // Reference to subscription (nullable if subscription deleted) + collectionId: text("collection_id"), // Reference to collection (nullable, for playlist tasks) + authorUrl: text("author_url").notNull(), + author: text("author").notNull(), + platform: text("platform").notNull(), // YouTube, Bilibili, etc. + status: text("status").notNull().default("active"), // 'active', 'paused', 'completed', 'cancelled' + totalVideos: integer("total_videos").default(0), // Total videos found + downloadedCount: integer("downloaded_count").default(0), // Number of videos downloaded + skippedCount: integer("skipped_count").default(0), // Number of videos skipped (already downloaded) + failedCount: integer("failed_count").default(0), // Number of videos that failed + currentVideoIndex: integer("current_video_index").default(0), // Current video being processed + createdAt: integer("created_at").notNull(), // Timestamp when task was created + updatedAt: integer("updated_at"), // Timestamp of last update + completedAt: integer("completed_at"), // Timestamp when task completed + error: text("error"), // Error message if task failed + } +); diff --git a/backend/src/middleware/roleBasedAuthMiddleware.ts b/backend/src/middleware/roleBasedAuthMiddleware.ts new file mode 100644 index 0000000..3604fef --- /dev/null +++ b/backend/src/middleware/roleBasedAuthMiddleware.ts @@ -0,0 +1,60 @@ +import { NextFunction, Request, Response } from "express"; + +/** + * Middleware to enforce role-based access control + * Visitors (userRole === 'visitor') are restricted to read-only operations + * Admins (userRole === 'admin') have full access + * Unauthenticated users are handled by loginEnabled setting + */ +export const roleBasedAuthMiddleware = ( + req: Request, + res: Response, + next: NextFunction +): void => { + // If user is Admin, allow all requests + if (req.user?.role === "admin") { + next(); + return; + } + + // If user is Visitor, restrict to read-only + if (req.user?.role === "visitor") { + // Allow GET requests (read-only) + if (req.method === "GET") { + next(); + return; + } + + // Allow authentication-related POST requests + if (req.method === "POST") { + // Allow verify-password requests + if ( + req.path.includes("/verify-password") || + req.url.includes("/verify-password") + ) { + next(); + return; + } + + // Allow passkey authentication + if ( + req.path.includes("/settings/passkeys/authenticate") || + req.url.includes("/settings/passkeys/authenticate") + ) { + next(); + return; + } + } + + // Block all other write operations (POST, PUT, DELETE, PATCH) + res.status(403).json({ + success: false, + error: "Visitor role: Write operations are not allowed. Read-only access only.", + }); + return; + } + + // For unauthenticated users, allow the request to proceed + // (loginEnabled check and other auth logic will handle it) + next(); +}; diff --git a/backend/src/middleware/roleBasedSettingsMiddleware.ts b/backend/src/middleware/roleBasedSettingsMiddleware.ts new file mode 100644 index 0000000..05fd590 --- /dev/null +++ b/backend/src/middleware/roleBasedSettingsMiddleware.ts @@ -0,0 +1,85 @@ +import { NextFunction, Request, Response } from "express"; + +/** + * Middleware specifically for settings routes with role-based access control + * Visitors can only read settings and update CloudFlare tunnel settings + * Admins have full access to all settings + */ +export const roleBasedSettingsMiddleware = ( + req: Request, + res: Response, + next: NextFunction +): void => { + // If user is Admin, allow all requests + if (req.user?.role === "admin") { + next(); + return; + } + + // If user is Visitor, restrict to read-only and CloudFlare updates + if (req.user?.role === "visitor") { + // Allow GET requests (read-only) + if (req.method === "GET") { + next(); + return; + } + + // For POST requests, check if it's authentication or CloudFlare settings + if (req.method === "POST") { + // Allow verify-password requests + if ( + req.path.includes("/verify-password") || + req.url.includes("/verify-password") + ) { + next(); + return; + } + + // Allow passkey authentication + if ( + req.path.includes("/passkeys/authenticate") || + req.url.includes("/passkeys/authenticate") + ) { + next(); + return; + } + + const body = req.body || {}; + + // Allow CloudFlare tunnel settings updates (read-only access mechanism) + const isOnlyCloudflareUpdate = + (body.cloudflaredTunnelEnabled !== undefined || + body.cloudflaredToken !== undefined) && + Object.keys(body).every( + (key) => + key === "cloudflaredTunnelEnabled" || + key === "cloudflaredToken" + ); + + if (isOnlyCloudflareUpdate) { + // Allow CloudFlare settings updates + next(); + return; + } + + // Block all other settings updates + res.status(403).json({ + success: false, + error: + "Visitor role: Only reading settings and updating CloudFlare settings is allowed.", + }); + return; + } + + // Block all other write operations (PUT, DELETE, PATCH) + res.status(403).json({ + success: false, + error: "Visitor role: Write operations are not allowed.", + }); + return; + } + + // For unauthenticated users, allow the request to proceed + // (loginEnabled check and other auth logic will handle it) + next(); +}; diff --git a/backend/src/routes/settingsRoutes.ts b/backend/src/routes/settingsRoutes.ts index 7ae5f21..d1614ba 100644 --- a/backend/src/routes/settingsRoutes.ts +++ b/backend/src/routes/settingsRoutes.ts @@ -23,6 +23,8 @@ import { getResetPasswordCooldown, resetPassword, verifyPassword, + verifyAdminPassword, + verifyVisitorPassword, } from "../controllers/passwordController"; import { checkPasskeysExist, @@ -56,7 +58,9 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus)); // Password routes router.get("/password-enabled", asyncHandler(getPasswordEnabled)); router.get("/reset-password-cooldown", asyncHandler(getResetPasswordCooldown)); -router.post("/verify-password", asyncHandler(verifyPassword)); +router.post("/verify-password", asyncHandler(verifyPassword)); // Deprecated, use verify-admin-password or verify-visitor-password +router.post("/verify-admin-password", asyncHandler(verifyAdminPassword)); +router.post("/verify-visitor-password", asyncHandler(verifyVisitorPassword)); router.post("/reset-password", asyncHandler(resetPassword)); // Passkey routes diff --git a/backend/src/server.ts b/backend/src/server.ts index 7d623da..7dd1763 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -13,8 +13,8 @@ import { } from "./config/paths"; import { runMigrations } from "./db/migrate"; import { authMiddleware } from "./middleware/authMiddleware"; -import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware"; -import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware"; +import { roleBasedAuthMiddleware } from "./middleware/roleBasedAuthMiddleware"; +import { roleBasedSettingsMiddleware } from "./middleware/roleBasedSettingsMiddleware"; import apiRoutes from "./routes/api"; import settingsRoutes from "./routes/settingsRoutes"; import { cloudflaredService } from "./services/cloudflaredService"; @@ -246,10 +246,10 @@ const startServer = async () => { // API Routes // Apply auth middleware to all API routes app.use("/api", authMiddleware); - // Apply visitor mode middleware to all API routes - app.use("/api", visitorModeMiddleware, apiRoutes); - // Use separate middleware for settings that allows disabling visitor mode - app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes); + // Apply role-based access control middleware to all API routes + app.use("/api", roleBasedAuthMiddleware, apiRoutes); + // Use separate middleware for settings with role-based access control + app.use("/api/settings", roleBasedSettingsMiddleware, settingsRoutes); // SPA Fallback for Frontend app.get("*", (req, res) => { diff --git a/backend/src/services/passkeyService.ts b/backend/src/services/passkeyService.ts index 4ae6b6a..73f7305 100644 --- a/backend/src/services/passkeyService.ts +++ b/backend/src/services/passkeyService.ts @@ -1,14 +1,14 @@ import type { - GenerateAuthenticationOptionsOpts, - GenerateRegistrationOptionsOpts, - VerifyAuthenticationResponseOpts, - VerifyRegistrationResponseOpts, + GenerateAuthenticationOptionsOpts, + GenerateRegistrationOptionsOpts, + VerifyAuthenticationResponseOpts, + VerifyRegistrationResponseOpts, } from "@simplewebauthn/server"; import { - generateAuthenticationOptions, - generateRegistrationOptions, - verifyAuthenticationResponse, - verifyRegistrationResponse, + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, } from "@simplewebauthn/server"; import { logger } from "../utils/logger"; import { generateToken } from "./authService"; @@ -309,10 +309,10 @@ export async function verifyPasskeyAuthentication( savePasskeys(updatedPasskeys); logger.info("Passkey authentication successful"); - + // Generate admin token (Passkeys are currently only for admins) const token = generateToken({ role: "admin" }); - + return { verified: true, token, role: "admin" }; } diff --git a/backend/src/services/passwordService.ts b/backend/src/services/passwordService.ts index 612d2a5..325b1df 100644 --- a/backend/src/services/passwordService.ts +++ b/backend/src/services/passwordService.ts @@ -43,9 +43,7 @@ export function isPasswordEnabled(): { /** * Verify password for authentication - */ -/** - * Verify password for authentication + * @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security */ import { generateToken } from "./authService"; @@ -105,14 +103,10 @@ export async function verifyPassword( } } - // 2. Check Visitor Password (only if visitorMode is enabled AND visitorPassword is set) - // Actually, user said: "When visitor user enable... login from this password input user role is Visitor" - // So we check if visitorMode is enabled in settings. - if (mergedSettings.visitorMode && mergedSettings.visitorPassword) { - // NOTE: visitorPassword might not be hashed yet if we just added it? - // Step 6 in Plan said "Update Settings type", but we didn't discuss hashing. - // We should probably hash it when saving too. - // For now, assuming it WILL be hashed. I'll need to update `settingsValidationService` to hash it. + // 2. Check Visitor Password (if visitorPassword is set) + // Permission control is now based on user role, not visitorMode setting + // If password matches visitorPassword, assign visitor role + if (mergedSettings.visitorPassword) { const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword); if (isVisitorMatch) { loginAttemptService.resetFailedAttempts(); @@ -133,6 +127,137 @@ export async function verifyPassword( }; } +/** + * Verify admin password for authentication + * Only checks admin password, not visitor password + */ +export async function verifyAdminPassword( + password: string +): Promise<{ + success: boolean; + role?: "admin"; + token?: string; + waitTime?: number; + failedAttempts?: number; + message?: string; +}> { + const settings = storageService.getSettings(); + const mergedSettings = { ...defaultSettings, ...settings }; + + // Check if password login is allowed (defaults to true for backward compatibility) + const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false; + + if (!passwordLoginAllowed) { + return { + success: false, + message: "Password login is not allowed. Please use passkey authentication.", + }; + } + + // Check if user can attempt login (wait time check) + const remainingWaitTime = loginAttemptService.canAttemptLogin(); + if (remainingWaitTime > 0) { + return { + success: false, + waitTime: remainingWaitTime, + message: "Too many failed attempts. Please wait before trying again.", + }; + } + + // Check Admin Password only + if (mergedSettings.password) { + const isAdminMatch = await bcrypt.compare(password, mergedSettings.password); + if (isAdminMatch) { + loginAttemptService.resetFailedAttempts(); + const token = generateToken({ role: "admin" }); + return { success: true, role: "admin", token }; + } + } else { + // If no admin password set, and login enabled, allow as admin + if (mergedSettings.loginEnabled) { + loginAttemptService.resetFailedAttempts(); + const token = generateToken({ role: "admin" }); + return { success: true, role: "admin", token }; + } + } + + // No match - record failed attempt + const waitTime = loginAttemptService.recordFailedAttempt(); + const failedAttempts = loginAttemptService.getFailedAttempts(); + + return { + success: false, + waitTime, + failedAttempts, + message: "Incorrect admin password", + }; +} + +/** + * Verify visitor password for authentication + * Only checks visitor password, not admin password + */ +export async function verifyVisitorPassword( + password: string +): Promise<{ + success: boolean; + role?: "visitor"; + token?: string; + waitTime?: number; + failedAttempts?: number; + message?: string; +}> { + const settings = storageService.getSettings(); + const mergedSettings = { ...defaultSettings, ...settings }; + + // Check if password login is allowed (defaults to true for backward compatibility) + const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false; + + if (!passwordLoginAllowed) { + return { + success: false, + message: "Password login is not allowed. Please use passkey authentication.", + }; + } + + // Check if user can attempt login (wait time check) + const remainingWaitTime = loginAttemptService.canAttemptLogin(); + if (remainingWaitTime > 0) { + return { + success: false, + waitTime: remainingWaitTime, + message: "Too many failed attempts. Please wait before trying again.", + }; + } + + // Check Visitor Password only + if (mergedSettings.visitorPassword) { + const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword); + if (isVisitorMatch) { + loginAttemptService.resetFailedAttempts(); + const token = generateToken({ role: "visitor" }); + return { success: true, role: "visitor", token }; + } + } else { + // No visitor password set + return { + success: false, + message: "Visitor password is not configured.", + }; + } + + // No match - record failed attempt + const waitTime = loginAttemptService.recordFailedAttempt(); + const failedAttempts = loginAttemptService.getFailedAttempts(); + + return { + success: false, + waitTime, + failedAttempts, + message: "Incorrect visitor password", + }; +} + /** * Hash a password */ diff --git a/backend/src/services/settingsValidationService.ts b/backend/src/services/settingsValidationService.ts index 783bf91..92ed735 100644 --- a/backend/src/services/settingsValidationService.ts +++ b/backend/src/services/settingsValidationService.ts @@ -26,7 +26,9 @@ export function validateSettings(newSettings: Partial): void { } /** - * Check if visitor mode restrictions should apply + * @deprecated This function is deprecated. Permission control is now handled by role-based middleware. + * This function is kept for backward compatibility but should not be used. + * Use roleBasedSettingsMiddleware instead. */ export function checkVisitorModeRestrictions( existingSettings: Settings, @@ -35,42 +37,9 @@ export function checkVisitorModeRestrictions( allowed: boolean; error?: string; } { - // If visitor mode is not enabled, no restrictions - if (existingSettings.visitorMode !== true) { - return { allowed: true }; - } - - // If visitorMode is being explicitly set to false, allow the update - if (newSettings.visitorMode === false) { - return { allowed: true }; - } - - // If visitorMode is explicitly set to true (already enabled), allow but only update visitorMode - if (newSettings.visitorMode === true) { - return { allowed: true }; - } - - // Allow CloudFlare tunnel settings updates (read-only access mechanism, doesn't violate visitor mode) - const isOnlyCloudflareUpdate = - (newSettings.cloudflaredTunnelEnabled !== undefined || - newSettings.cloudflaredToken !== undefined) && - Object.keys(newSettings).every( - (key) => - key === "cloudflaredTunnelEnabled" || - key === "cloudflaredToken" || - key === "visitorMode" - ); - - if (isOnlyCloudflareUpdate) { - return { allowed: true }; - } - - // Block all other changes - return { - allowed: false, - error: - "Visitor mode is enabled. Only disabling visitor mode or updating CloudFlare settings is allowed.", - }; + // Always allow - permission control is now handled by role-based middleware + // This function is kept for backward compatibility + return { allowed: true }; } /** diff --git a/backend/src/services/storageService/index.ts b/backend/src/services/storageService/index.ts index c668612..72dad90 100644 --- a/backend/src/services/storageService/index.ts +++ b/backend/src/services/storageService/index.ts @@ -10,29 +10,29 @@ export { initializeStorage } from "./initialization"; // Download Status export { addActiveDownload, - updateActiveDownload, + getDownloadStatus, removeActiveDownload, setQueuedDownloads, - getDownloadStatus, + updateActiveDownload, } from "./downloadStatus"; // Download History export { addDownloadHistoryItem, + clearDownloadHistory, getDownloadHistory, removeDownloadHistoryItem, - clearDownloadHistory, } from "./downloadHistory"; // Video Download Tracking export { checkVideoDownloadBySourceId, checkVideoDownloadByUrl, - recordVideoDownload, + handleVideoDownloadCheck, markVideoDownloadDeleted, + recordVideoDownload, updateVideoDownloadRecord, verifyVideoExists, - handleVideoDownloadCheck, } from "./videoDownloadTracking"; // Settings @@ -40,31 +40,30 @@ export { getSettings, saveSettings } from "./settings"; // Videos export { - getVideos, - getVideoBySourceUrl, - getVideoById, + deleteVideo, formatLegacyFilenames, + getVideoById, + getVideoBySourceUrl, + getVideos, saveVideo, updateVideo, - deleteVideo, } from "./videos"; // Collections export { - getCollections, - getCollectionById, - getCollectionByVideoId, - getCollectionByName, - generateUniqueCollectionName, - saveCollection, + addVideoToCollection, atomicUpdateCollection, deleteCollection, - addVideoToCollection, - removeVideoFromCollection, - deleteCollectionWithFiles, deleteCollectionAndVideos, + deleteCollectionWithFiles, + generateUniqueCollectionName, + getCollectionById, + getCollectionByName, + getCollectionByVideoId, + getCollections, + removeVideoFromCollection, + saveCollection, } from "./collections"; // File Helpers -export { findVideoFile, findImageFile, moveFile } from "./fileHelpers"; - +export { findImageFile, findVideoFile, moveFile } from "./fileHelpers"; diff --git a/backend/src/services/storageService/initialization.ts b/backend/src/services/storageService/initialization.ts index 280990b..d20d7d8 100644 --- a/backend/src/services/storageService/initialization.ts +++ b/backend/src/services/storageService/initialization.ts @@ -8,9 +8,9 @@ import { UPLOADS_DIR, VIDEOS_DIR, } from "../../config/paths"; -import { MigrationError } from "../../errors/DownloadErrors"; import { db, sqlite } from "../../db"; import { downloads, videos } from "../../db/schema"; +import { MigrationError } from "../../errors/DownloadErrors"; import { logger } from "../../utils/logger"; import { findVideoFile } from "./fileHelpers"; @@ -36,7 +36,10 @@ export function initializeStorage(): void { fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2)); logger.info("Cleared active downloads on startup"); } catch (error) { - logger.error("Error resetting active downloads", error instanceof Error ? error : new Error(String(error))); + logger.error( + "Error resetting active downloads", + error instanceof Error ? error : new Error(String(error)) + ); fs.writeFileSync( STATUS_DATA_PATH, JSON.stringify({ activeDownloads: [], queuedDownloads: [] }, null, 2) @@ -49,7 +52,10 @@ export function initializeStorage(): void { db.delete(downloads).where(eq(downloads.status, "active")).run(); logger.info("Cleared active downloads from database on startup"); } catch (error) { - logger.error("Error clearing active downloads from database", error instanceof Error ? error : new Error(String(error))); + logger.error( + "Error clearing active downloads from database", + error instanceof Error ? error : new Error(String(error)) + ); } // Check and migrate tags column if needed @@ -65,7 +71,10 @@ export function initializeStorage(): void { logger.info("Migration successful."); } } catch (error) { - logger.error("Error checking/migrating tags column", error instanceof Error ? error : new Error(String(error))); + logger.error( + "Error checking/migrating tags column", + error instanceof Error ? error : new Error(String(error)) + ); throw new MigrationError( "Failed to migrate tags column", "tags_column", @@ -198,7 +207,10 @@ export function initializeStorage(): void { .run(); } catch (indexError) { // Indexes might already exist, ignore error - logger.debug("Index creation skipped (may already exist)", indexError instanceof Error ? indexError : new Error(String(indexError))); + logger.debug( + "Index creation skipped (may already exist)", + indexError instanceof Error ? indexError : new Error(String(indexError)) + ); } // Check download_history table for video_id, downloaded_at, deleted_at columns @@ -282,7 +294,10 @@ export function initializeStorage(): void { ); } } catch (error) { - logger.error("Error backfilling video_id in download history", error instanceof Error ? error : new Error(String(error))); + logger.error( + "Error backfilling video_id in download history", + error instanceof Error ? error : new Error(String(error)) + ); } } catch (error) { logger.error( diff --git a/backend/src/types/settings.ts b/backend/src/types/settings.ts index db0bbf9..b9b8ab7 100644 --- a/backend/src/types/settings.ts +++ b/backend/src/types/settings.ts @@ -23,6 +23,11 @@ export interface Settings { proxyOnlyYoutube?: boolean; moveSubtitlesToVideoFolder?: boolean; moveThumbnailsToVideoFolder?: boolean; + /** + * @deprecated Visitor mode is deprecated. Permission control is now based on user role. + * This field is kept for backward compatibility but is no longer used. + * Use userRole ('admin' | 'visitor') from JWT token instead. + */ visitorMode?: boolean; visitorPassword?: string; infiniteScroll?: boolean; @@ -55,5 +60,3 @@ export const defaultSettings: Settings = { videoColumns: 4, pauseOnFocusLoss: false, }; - - diff --git a/frontend/src/components/Settings/SecuritySettings.tsx b/frontend/src/components/Settings/SecuritySettings.tsx index c7990f6..a9d78db 100644 --- a/frontend/src/components/Settings/SecuritySettings.tsx +++ b/frontend/src/components/Settings/SecuritySettings.tsx @@ -148,16 +148,15 @@ const SecuritySettings: React.FC = ({ settings, onChange return ( - onChange('loginEnabled', e.target.checked)} - disabled={settings.visitorMode} // Locked enabled if visitor mode is on + onChange('loginEnabled', e.target.checked)} + /> + } + label={t('enableLogin')} /> - } - label={t('enableLogin')} - /> {settings.loginEnabled && ( @@ -194,53 +193,27 @@ const SecuritySettings: React.FC = ({ settings, onChange - { - const enabled = e.target.checked; - onChange('visitorMode', enabled); - // Lock loginEnabled to true if visitor mode is enabled - if (enabled) { - if (!settings.loginEnabled) { - onChange('loginEnabled', true); - } - if (!settings.visitorPassword) { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; - const array = new Uint32Array(12); - window.crypto.getRandomValues(array); - const newPassword = Array.from(array, x => chars[x % chars.length]).join(''); - onChange('visitorPassword', newPassword); - } - } - }} - disabled={!settings.loginEnabled && settings.visitorMode} // Unlock only if login is enabled? Actually user said "loginEnabled should be locked enabled". So if visitor mode is ON, loginEnabled switch (above) should be disabled or force checked. - /> - } - label={t('visitorUser') || 'Visitor User'} - /> + + {t('visitorUser') || 'Visitor User'} + - {t('visitorUserHelper') || 'Enable a restricted Visitor User role. Visitors have read-only access and cannot change settings.'} + {t('visitorUserHelper') || 'Set a password for the Visitor User role. Users logging in with this password will have read-only access and cannot change settings.'} - - {settings.visitorMode && ( - onChange('visitorPassword', e.target.value)} - helperText={ - settings.isVisitorPasswordSet - ? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.') - : (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.') - } - /> - )} + onChange('visitorPassword', e.target.value)} + helperText={ + settings.isVisitorPasswordSet + ? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.') + : (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.') + } + /> ({ visitorMode: false, - isLoading: true, + isLoading: false, }); - +/** + * @deprecated Use useAuth().userRole === 'visitor' instead + */ export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { userRole } = useAuth(); - const { data: settingsData, isLoading } = useQuery({ - queryKey: ['settings'], - queryFn: async () => { - const response = await axios.get(`${API_URL}/settings`); - return response.data; - }, - refetchInterval: 30000, // Refetch every 30 seconds (reduced frequency) - staleTime: 10000, // Consider data fresh for 10 seconds - gcTime: 10 * 60 * 1000, // Garbage collect after 10 minutes - }); + const { userRole, checkingAuth } = useAuth(); - // Visitor mode is active if enabled in settings AND user is not an admin - const visitorMode = settingsData?.visitorMode === true && userRole !== 'admin'; + // Visitor mode is now based solely on userRole + // No longer depends on settings.visitorMode + const visitorMode = userRole === 'visitor'; + const isLoading = checkingAuth; return ( @@ -41,6 +34,9 @@ export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ childre ); }; +/** + * @deprecated Use useAuth().userRole === 'visitor' instead + */ export const useVisitorMode = () => { const context = useContext(VisitorModeContext); if (!context) { diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 314798f..bb4457b 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -26,7 +26,6 @@ import AlertModal from '../components/AlertModal'; import ConfirmationModal from '../components/ConfirmationModal'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; -import { useVisitorMode } from '../contexts/VisitorModeContext'; import getTheme from '../theme'; import { getWebAuthnErrorTranslationKey } from '../utils/translations'; @@ -48,7 +47,6 @@ const LoginPage: React.FC = () => { const [resetPasswordCooldown, setResetPasswordCooldown] = useState(0); // in milliseconds const { t } = useLanguage(); const { login } = useAuth(); - const { visitorMode } = useVisitorMode(); const queryClient = useQueryClient(); // Fetch website name and settings from settings @@ -68,6 +66,8 @@ const LoginPage: React.FC = () => { const passwordLoginAllowed = settingsData?.passwordLoginAllowed !== false; const allowResetPassword = settingsData?.allowResetPassword !== false; + // Show visitor tab if visitorPassword is set (no longer depends on visitorMode setting) + const showVisitorTab = !!settingsData?.isVisitorPasswordSet; // Update website name when settings are loaded useEffect(() => { @@ -194,9 +194,9 @@ const LoginPage: React.FC = () => { setAlertOpen(true); }; - const loginMutation = useMutation({ + const adminLoginMutation = useMutation({ mutationFn: async (passwordToVerify: string) => { - const response = await axios.post(`${API_URL}/settings/verify-password`, { password: passwordToVerify }); + const response = await axios.post(`${API_URL}/settings/verify-admin-password`, { password: passwordToVerify }); return response.data; }, onSuccess: (data) => { @@ -241,13 +241,55 @@ const LoginPage: React.FC = () => { + const visitorLoginMutation = useMutation({ + mutationFn: async (passwordToVerify: string) => { + const response = await axios.post(`${API_URL}/settings/verify-visitor-password`, { password: passwordToVerify }); + return response.data; + }, + onSuccess: (data) => { + if (data.success) { + setWaitTime(0); // Reset wait time on success + login(data.token, data.role); + } else { + // Handle failures (incorrect password or too many attempts) + const statusCode = data.statusCode || 401; + const responseData = data; + + if (statusCode === 429) { + // Too many attempts - wait time required + const waitTimeMs = responseData.waitTime || 0; + setWaitTime(waitTimeMs); + const formattedTime = formatWaitTime(waitTimeMs); + showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`); + } else if (statusCode === 401) { + // Incorrect password - check if wait time is returned + const waitTimeMs = responseData.waitTime || 0; + if (waitTimeMs > 0) { + setWaitTime(waitTimeMs); + const formattedTime = formatWaitTime(waitTimeMs); + showAlert(t('error'), `${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`); + } else { + showAlert(t('error'), t('incorrectPassword')); + } + } else { + showAlert(t('error'), t('loginFailed')); + } + } + }, + onError: (err: any) => { + console.error('Login error:', err); + // Handle actual network errors or unexpected 500s + showAlert(t('error'), t('loginFailed')); + } + }); + const handleVisitorSubmit = (e: React.FormEvent) => { e.preventDefault(); if (waitTime > 0) { return; } setError(''); - loginMutation.mutate(visitorPassword); + visitorLoginMutation.mutate(visitorPassword); } const resetPasswordMutation = useMutation({ @@ -341,7 +383,7 @@ const LoginPage: React.FC = () => { return; // Don't allow submission if wait time is active } setError(''); - loginMutation.mutate(password); + adminLoginMutation.mutate(password); }; const handleResetPassword = () => { @@ -423,12 +465,7 @@ const LoginPage: React.FC = () => { {websiteName} - {visitorMode && ( - - {t('visitorMode')} - - )} - {websiteName !== 'MyTube' && !visitorMode && ( + {websiteName !== 'MyTube' && ( Powered by MyTube @@ -442,7 +479,7 @@ const LoginPage: React.FC = () => { - {visitorMode && ( + {showVisitorTab && ( setActiveTab(newValue)} aria-label="login tabs" variant="fullWidth"> @@ -451,14 +488,14 @@ const LoginPage: React.FC = () => { )} - {/* Admin Tab Panel (and default view when visitor mode is off) */} + {/* Admin Tab Panel (and default view when visitor tab is not shown) */} {/* Visitor Tab Panel */} - {visitorMode && ( + {showVisitorTab && (