feat: enhance visitor mode
This commit is contained in:
236
REFACTOR_PLAN.md
Normal file
236
REFACTOR_PLAN.md
Normal file
@@ -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`
|
||||
11
backend/drizzle/0009_brief_stingray.sql
Normal file
11
backend/drizzle/0009_brief_stingray.sql
Normal file
@@ -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
|
||||
);
|
||||
907
backend/drizzle/meta/0009_snapshot.json
Normal file
907
backend/drizzle/meta/0009_snapshot.json
Normal file
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
60
backend/src/middleware/roleBasedAuthMiddleware.ts
Normal file
60
backend/src/middleware/roleBasedAuthMiddleware.ts
Normal file
@@ -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();
|
||||
};
|
||||
85
backend/src/middleware/roleBasedSettingsMiddleware.ts
Normal file
85
backend/src/middleware/roleBasedSettingsMiddleware.ts
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,9 @@ export function validateSettings(newSettings: Partial<Settings>): 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -148,16 +148,15 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.loginEnabled}
|
||||
onChange={(e) => onChange('loginEnabled', e.target.checked)}
|
||||
disabled={settings.visitorMode} // Locked enabled if visitor mode is on
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.loginEnabled}
|
||||
onChange={(e) => onChange('loginEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('enableLogin')}
|
||||
/>
|
||||
}
|
||||
label={t('enableLogin')}
|
||||
/>
|
||||
|
||||
{settings.loginEnabled && (
|
||||
<Box sx={{ mt: 2, maxWidth: 400 }}>
|
||||
@@ -194,53 +193,27 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.visitorMode === true}
|
||||
onChange={(e) => {
|
||||
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'}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
{t('visitorUser') || 'Visitor User'}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{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.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{settings.visitorMode && (
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
label={t('visitorPassword') || 'Visitor Password'}
|
||||
type="text" // User said "It should be visible" - wait, "show a input ... It should be visible". Does it mean the input is visible, or the password text is visible? "let admin setup visior password. It should be visible." Usually setup inputs are passwords but maybe they want it visible to see what it is? let's stick to type="text" or "password" with show toggle. "It should be visible" likely means the input field itself appears. I will use standard password field for security but maybe default show? Or just text if implied. "It should be visible" logically refers to the input field appearing. Safe bet is standard password field behavior.
|
||||
value={settings.visitorPassword || ''}
|
||||
onChange={(e) => 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.')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
label={t('visitorPassword') || 'Visitor Password'}
|
||||
type="text"
|
||||
value={settings.visitorPassword || ''}
|
||||
onChange={(e) => 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.')
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { createContext, ReactNode, useContext } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @deprecated This context is kept for backward compatibility.
|
||||
* Permission control is now based on userRole from AuthContext.
|
||||
* Use `useAuth().userRole === 'visitor'` instead of `useVisitorMode().visitorMode`.
|
||||
*/
|
||||
interface VisitorModeContextType {
|
||||
visitorMode: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -14,25 +13,19 @@ interface VisitorModeContextType {
|
||||
|
||||
const VisitorModeContext = createContext<VisitorModeContextType>({
|
||||
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 (
|
||||
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = () => {
|
||||
<Typography variant="h4" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
|
||||
{websiteName}
|
||||
</Typography>
|
||||
{visitorMode && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.75rem', lineHeight: 1.2, mt: 0.5 }}>
|
||||
{t('visitorMode')}
|
||||
</Typography>
|
||||
)}
|
||||
{websiteName !== 'MyTube' && !visitorMode && (
|
||||
{websiteName !== 'MyTube' && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', lineHeight: 1.2, mt: 0.25 }}>
|
||||
Powered by MyTube
|
||||
</Typography>
|
||||
@@ -442,7 +479,7 @@ const LoginPage: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, width: '100%' }}>
|
||||
{visitorMode && (
|
||||
{showVisitorTab && (
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tabs value={activeTab} onChange={(_: React.SyntheticEvent, newValue: number) => setActiveTab(newValue)} aria-label="login tabs" variant="fullWidth">
|
||||
<Tab label={t('admin') || 'Admin'} id="login-tab-0" aria-controls="login-tabpanel-0" />
|
||||
@@ -451,14 +488,14 @@ const LoginPage: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Admin Tab Panel (and default view when visitor mode is off) */}
|
||||
{/* Admin Tab Panel (and default view when visitor tab is not shown) */}
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={visitorMode && activeTab !== 0}
|
||||
hidden={showVisitorTab && activeTab !== 0}
|
||||
id="login-tabpanel-0"
|
||||
aria-labelledby="login-tab-0"
|
||||
>
|
||||
{(visitorMode ? activeTab === 0 : true) && (
|
||||
{(showVisitorTab ? activeTab === 0 : true) && (
|
||||
<>
|
||||
{passwordLoginAllowed && (
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||
@@ -473,8 +510,8 @@ const LoginPage: React.FC = () => {
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus={!visitorMode || activeTab === 0}
|
||||
disabled={waitTime > 0 || loginMutation.isPending}
|
||||
autoFocus={!showVisitorTab || activeTab === 0}
|
||||
disabled={waitTime > 0 || adminLoginMutation.isPending}
|
||||
helperText={t('defaultPasswordHint') || "Default password: 123"}
|
||||
slotProps={{
|
||||
input: {
|
||||
@@ -497,9 +534,9 @@ const LoginPage: React.FC = () => {
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loginMutation.isPending || waitTime > 0}
|
||||
disabled={adminLoginMutation.isPending || waitTime > 0}
|
||||
>
|
||||
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
|
||||
{adminLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@@ -577,7 +614,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Visitor Tab Panel */}
|
||||
{visitorMode && (
|
||||
{showVisitorTab && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={activeTab !== 1}
|
||||
@@ -597,7 +634,7 @@ const LoginPage: React.FC = () => {
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
autoFocus={activeTab === 1}
|
||||
disabled={waitTime > 0 || loginMutation.isPending}
|
||||
disabled={waitTime > 0 || visitorLoginMutation.isPending}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
@@ -619,9 +656,9 @@ const LoginPage: React.FC = () => {
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={loginMutation.isPending || waitTime > 0}
|
||||
disabled={visitorLoginMutation.isPending || waitTime > 0}
|
||||
>
|
||||
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
|
||||
{visitorLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user