Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899bb8ee2e | ||
|
|
dc51b4405a | ||
|
|
3e44960ce7 | ||
|
|
91d53f04a4 | ||
|
|
e91ae4b314 | ||
|
|
a49dd31feb | ||
|
|
494b85d440 | ||
|
|
695489d72a | ||
|
|
a4eaaa3180 | ||
|
|
79530dbca2 | ||
|
|
f48066c045 | ||
|
|
46c8d7730f | ||
|
|
fbd55b0037 | ||
|
|
6490e1f912 | ||
|
|
16ba5ac1d4 | ||
|
|
f76acfdcf1 | ||
|
|
98ec0b342f | ||
|
|
c995eb3637 | ||
|
|
8e533e3615 | ||
|
|
7dbf5c895d | ||
|
|
eeac567523 | ||
|
|
10c857865c | ||
|
|
e7bdf182c5 | ||
|
|
a5e82b9e81 | ||
|
|
d99a210174 | ||
|
|
50cc94a44e | ||
|
|
ccd2729f71 | ||
|
|
a9f78647e4 | ||
|
|
e18f49d321 | ||
|
|
13de853a54 | ||
|
|
76d4269164 | ||
|
|
44b24543d0 | ||
|
|
b6fbf015a3 | ||
|
|
9c0afb0693 | ||
|
|
3717296bf2 | ||
|
|
fe8dd04f08 | ||
|
|
e0819ca42c | ||
|
|
092a79f635 | ||
|
|
9296390b82 | ||
|
|
35aa348824 | ||
|
|
1b9451bffa | ||
|
|
9968268975 | ||
|
|
ce544ff9c2 | ||
|
|
b6e3072350 | ||
|
|
85424624ca | ||
|
|
6fdfa90d01 | ||
|
|
c9657bad51 | ||
|
|
2d9d7b37a6 | ||
|
|
b8fcb05d51 | ||
|
|
90a24454f6 | ||
|
|
a56de30dd1 | ||
|
|
b8cc540f9d | ||
|
|
b546a4520e | ||
|
|
6bbb40eb11 | ||
|
|
c00b552ba9 | ||
|
|
845e1847f7 | ||
|
|
71d59a9e26 | ||
|
|
4e8d7553ea | ||
|
|
e1fb345094 | ||
|
|
351f1876d7 | ||
|
|
c32fa3e7ca | ||
|
|
b0428b9813 |
138
CHANGELOG.md
138
CHANGELOG.md
@@ -1,6 +1,144 @@
|
||||
# Change Log
|
||||
|
||||
|
||||
## v1.7.34 (2026-01-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add isVisitor check to BasicSettings and useVideoProgress (3e44960)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add refetchOnMount option to DownloadProvider (91d53f0)
|
||||
|
||||
## v1.7.33 (2026-01-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add support for SOCKS5 proxies in axios config (695489d)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Add default timeout for thumbnail downloads (494b85d)
|
||||
|
||||
### Chore
|
||||
|
||||
- chore: update doc (a4eaaa3)
|
||||
|
||||
## v1.7.32 (2026-01-04)
|
||||
|
||||
### Style
|
||||
|
||||
- style: Update cookie security settings for better usability (46c8d77)
|
||||
|
||||
## v1.7.31 (2026-01-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Implement helper for selecting best m3u8 URL (f76acfd)
|
||||
- feat: Add executeYtDlpJson function (98ec0b3)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Update axios configuration for downloading subtitles (c995eb3)
|
||||
|
||||
## v1.7.30 (2026-01-04)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Update mock SettingsPage test to include refetch (eeac567)
|
||||
|
||||
### Style
|
||||
|
||||
- style: Improve comments for YtDlpSettings file (e7bdf18)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Add file_location test and mock settings in ytdlpVideo (a5e82b9)
|
||||
|
||||
## v1.7.29 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Enable visitor user with password option (ccd2729)
|
||||
- feat: enhance visitor mode (e18f49d)
|
||||
- feat: enhance visitor mode (13de853)
|
||||
- feat: enhance visitor mode (76d4269)
|
||||
- feat: Add visitor mode in LoginPage component (44b2454)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Add role to response in passwordController tests (a9f7864)
|
||||
|
||||
## v1.7.28 (2026-01-03)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve m3u8 URL selection strategy (3717296)
|
||||
|
||||
## v1.7.27 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add endpoint for retrieving reset password cooldown (092a79f)
|
||||
- feat: Add WebAuthn error translations (9296390)
|
||||
|
||||
## v1.7.26 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add script to reset password securely (1b9451b)
|
||||
- feat: Add allowResetPassword setting and UI components (9968268)
|
||||
- feat: Add password login permission handling (ce544ff)
|
||||
|
||||
## v1.7.25 (2026-01-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: add passkey feature (6fdfa90)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Update formatUtils to use formatRelativeDownloadTime function (c9657ba)
|
||||
|
||||
## v1.7.24 (2026-01-01)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Explicitly preserve network-related options (90a2445)
|
||||
|
||||
## v1.7.23 (2026-01-01)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add logic to refresh thumbnail with random timestamp (6bbb40e)
|
||||
- feat: Add reset password route and update dependencies (c00b552)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add reset password route (845e184)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities (b0428b9)
|
||||
|
||||
## v1.7.22 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add risk command scanning for hook uploads (c32fa3e)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve handling of absolute paths in security functions (351f187)
|
||||
|
||||
## v1.7.21 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add hook functionality for task lifecycle (6f1a1cd)
|
||||
- feat: add task hooks (8ac9e99)
|
||||
|
||||
## v1.7.20 (2025-12-30)
|
||||
|
||||
### Chore
|
||||
|
||||
19
README-zh.md
19
README-zh.md
@@ -30,7 +30,7 @@
|
||||
- **搜索功能**:支持在本地库中搜索视频,或在线搜索 YouTube 视频。
|
||||
- **收藏夹**:创建自定义收藏夹以整理您的视频。
|
||||
- **订阅功能**:订阅您喜爱的频道,并在新视频发布时自动下载。
|
||||
- **登录保护**:通过密码登录页面保护您的应用。
|
||||
- **登录保护**:支持密码登录并可选使用通行密钥 (WebAuthn)。
|
||||
- **国际化**:支持多种语言,包括英语、中文、西班牙语、法语、德语、日语、韩语、阿拉伯语、葡萄牙语和俄语。
|
||||
- **分页功能**:支持分页浏览,高效管理大量视频。
|
||||
- **视频评分**:使用 5 星评级系统为您的视频评分。
|
||||
@@ -39,9 +39,10 @@
|
||||
- **视图模式**:在主页上切换收藏夹视图和视频视图。
|
||||
- **Cookie 管理**:支持上传 `cookies.txt` 以启用年龄限制或会员内容的下载。
|
||||
- **yt-dlp 配置**: 通过用户界面自定义全局 `yt-dlp` 参数、网络代理及其他高级设置。
|
||||
- **访客模式**:启用只读模式,允许查看视频但无法进行修改。非常适合与他人分享您的视频库。
|
||||
- **访客用户**:启用只读角色,便于分享但不允许修改。
|
||||
- **云存储集成**:下载后自动将视频和缩略图上传到云存储(OpenList/Alist)。
|
||||
- **Cloudflare Tunnel 集成**: 内置 Cloudflare Tunnel 支持,无需端口转发即可轻松将本地 MyTube 实例暴露到互联网。
|
||||
- **任务钩子**: 在下载任务的各个阶段(开始、成功、失败、取消)执行自定义 Shell 脚本,以实现集成和自动化。详见 [任务钩子指南](documents/zh/hooks-guide.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -89,23 +90,19 @@
|
||||
### 前端 (`frontend/.env`)
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5551/api
|
||||
VITE_BACKEND_URL=http://localhost:5551
|
||||
VITE_API_URL=/api
|
||||
VITE_BACKEND_URL=
|
||||
```
|
||||
|
||||
### 后端 (`backend/.env`)
|
||||
|
||||
```env
|
||||
PORT=5551
|
||||
UPLOAD_DIR=uploads
|
||||
VIDEO_DIR=uploads/videos
|
||||
IMAGE_DIR=uploads/images
|
||||
SUBTITLES_DIR=uploads/subtitles
|
||||
DATA_DIR=data
|
||||
MAX_FILE_SIZE=500000000
|
||||
```
|
||||
|
||||
复制前端和后端目录中的 `.env.example` 文件以创建您自己的 `.env` 文件。
|
||||
默认数据与上传路径位于 `backend/data` 和 `backend/uploads`(相对于后端工作目录)。
|
||||
|
||||
将 `backend/.env.example` 复制为 `backend/.env` 并按需调整。前端已提供 `frontend/.env`,可使用 `frontend/.env.local` 覆盖默认值。
|
||||
|
||||
## 数据库
|
||||
|
||||
|
||||
19
README.md
19
README.md
@@ -29,7 +29,7 @@ Self-hosted downloader and player for YouTube, Bilibili, MissAV, and [yt-dlp sit
|
||||
- **Auto Subtitles**: Automatically download YouTube / Bilibili default language subtitles.
|
||||
- **Search**: Search for videos locally in your library or online via YouTube.
|
||||
- **Collections**: Organize videos into custom collections for easy access.
|
||||
- **Login Protection**: Secure your application with a password login page.
|
||||
- **Login Protection**: Secure your application with password login and optional passkeys (WebAuthn).
|
||||
- **Internationalization**: Support for multiple languages including English, Chinese, Spanish, French, German, Japanese, Korean, Arabic, Portuguese, and Russian.
|
||||
- **Pagination**: Efficiently browse large libraries with pagination support.
|
||||
- **Subscriptions**: Manage subscriptions to channels or creators to automatically download new content.
|
||||
@@ -39,9 +39,10 @@ Self-hosted downloader and player for YouTube, Bilibili, MissAV, and [yt-dlp sit
|
||||
- **View Modes**: Toggle between Collection View and Video View on the home page.
|
||||
- **Cookie Management**: Support for uploading `cookies.txt` to enable downloading of age-restricted or premium content.
|
||||
- **yt-dlp Configuration**: Customize global `yt-dlp` arguments, network proxy, and other advanced settings via settings page.
|
||||
- **Visitor Mode**: Enable read-only mode to allow viewing videos without modification capabilities. Perfect for sharing your library with others.
|
||||
- **Visitor User**: Enable a read-only role for safe sharing without modification capabilities.
|
||||
- **Cloud Storage Integration**: Automatically upload videos and thumbnails to cloud storage (OpenList/Alist) after download.
|
||||
- **Cloudflare Tunnel Integration**: Built-in Cloudflare Tunnel support to easily expose your local MyTube instance to the internet without port forwarding.
|
||||
- **Task Hooks**: Execute custom shell scripts at various stages of a download task (start, success, fail, cancel) for integration and automation. See [Task Hooks Guide](documents/en/hooks-guide.md).
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -89,23 +90,19 @@ The application uses environment variables for configuration.
|
||||
### Frontend (`frontend/.env`)
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5551/api
|
||||
VITE_BACKEND_URL=http://localhost:5551
|
||||
VITE_API_URL=/api
|
||||
VITE_BACKEND_URL=
|
||||
```
|
||||
|
||||
### Backend (`backend/.env`)
|
||||
|
||||
```env
|
||||
PORT=5551
|
||||
UPLOAD_DIR=uploads
|
||||
VIDEO_DIR=uploads/videos
|
||||
IMAGE_DIR=uploads/images
|
||||
SUBTITLES_DIR=uploads/subtitles
|
||||
DATA_DIR=data
|
||||
MAX_FILE_SIZE=500000000
|
||||
```
|
||||
|
||||
Copy the `.env.example` files in both frontend and backend directories to create your own `.env` files.
|
||||
Data and uploads are stored under `backend/data` and `backend/uploads` by default (relative to the backend working directory).
|
||||
|
||||
Copy `backend/.env.example` to `backend/.env` and adjust as needed. The frontend ships with `frontend/.env`; use `frontend/.env.local` to override defaults.
|
||||
|
||||
## Database
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
469
backend/package-lock.json
generated
469
backend/package-lock.json
generated
@@ -1,27 +1,32 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.34",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -30,6 +35,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
@@ -628,6 +634,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -791,6 +803,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -814,6 +832,165 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
|
||||
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz",
|
||||
"integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz",
|
||||
"integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
|
||||
"integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz",
|
||||
"integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz",
|
||||
"integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz",
|
||||
"integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pfx": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
|
||||
"integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
|
||||
"integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz",
|
||||
"integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz",
|
||||
"integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -1177,6 +1354,25 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz",
|
||||
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@peculiar/x509": "^1.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||
@@ -1233,7 +1429,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -1255,12 +1450,20 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -1296,8 +1499,8 @@
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -1308,7 +1511,6 @@
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -1332,7 +1534,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonfile": {
|
||||
@@ -1345,6 +1546,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -1356,6 +1568,12 @@
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1373,7 +1591,6 @@
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -1391,21 +1608,18 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -1415,7 +1629,6 @@
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -1427,7 +1640,6 @@
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -1808,6 +2020,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2162,6 +2388,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -2464,14 +2696,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
@@ -2934,6 +3179,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -3267,39 +3521,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz",
|
||||
"integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -3312,6 +3566,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -4235,12 +4504,103 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -5109,6 +5469,24 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -5190,6 +5568,12 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -6101,6 +6485,24 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@@ -6173,7 +6575,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.34",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
@@ -9,6 +9,7 @@
|
||||
"generate": "drizzle-kit generate",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"reset-password": "ts-node scripts/reset-password.ts",
|
||||
"postinstall": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -16,18 +17,23 @@
|
||||
"license": "ISC",
|
||||
"description": "Backend for MyTube video streaming website",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -36,6 +42,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
|
||||
155
backend/scripts/reset-password.ts
Normal file
155
backend/scripts/reset-password.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Script to directly reset password and enable password login in the database
|
||||
*
|
||||
* Usage:
|
||||
* npm run reset-password [new-password]
|
||||
* or
|
||||
* ts-node scripts/reset-password.ts [new-password]
|
||||
*
|
||||
* If no password is provided, a random 8-character password will be generated.
|
||||
* The script will:
|
||||
* 1. Hash the password using bcrypt
|
||||
* 2. Update the password in the settings table
|
||||
* 3. Set passwordLoginAllowed to true
|
||||
* 4. Set loginEnabled to true
|
||||
* 5. Display the new password (if generated)
|
||||
*
|
||||
* Examples:
|
||||
* npm run reset-password # Generate random password
|
||||
* npm run reset-password mynewpassword123 # Set specific password
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Determine database path
|
||||
const ROOT_DIR = process.cwd();
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT_DIR, "data");
|
||||
// Normalize and resolve paths to prevent path traversal
|
||||
const normalizedDataDir = path.normalize(path.resolve(DATA_DIR));
|
||||
const dbPath = path.normalize(path.resolve(normalizedDataDir, "mytube.db"));
|
||||
|
||||
// Validate that the database path is within the expected directory
|
||||
// This prevents path traversal attacks via environment variables
|
||||
const resolvedDataDir = path.resolve(normalizedDataDir);
|
||||
const resolvedDbPath = path.resolve(dbPath);
|
||||
if (!resolvedDbPath.startsWith(resolvedDataDir + path.sep) && resolvedDbPath !== resolvedDataDir) {
|
||||
console.error("Error: Invalid database path detected (path traversal attempt)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure SQLite database for compatibility
|
||||
*/
|
||||
function configureDatabase(db: Database.Database): void {
|
||||
db.pragma("journal_mode = DELETE");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random password
|
||||
*/
|
||||
function generateRandomPassword(length: number = 8): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const randomBytes = crypto.randomBytes(length);
|
||||
return Array.from(randomBytes, (byte) => chars.charAt(byte % chars.length)).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
*/
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to reset password and enable password login
|
||||
*/
|
||||
async function resetPassword(newPassword?: string): Promise<void> {
|
||||
// Check if database exists
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`Error: Database not found at ${dbPath}`);
|
||||
console.error("Please ensure the MyTube backend has been started at least once.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate password if not provided
|
||||
const password = newPassword || generateRandomPassword(8);
|
||||
const isGenerated = !newPassword;
|
||||
|
||||
// Hash the password
|
||||
console.log("Hashing password...");
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Connect to database
|
||||
console.log(`Connecting to database at ${dbPath}...`);
|
||||
const db = new Database(dbPath);
|
||||
configureDatabase(db);
|
||||
|
||||
try {
|
||||
// Start transaction
|
||||
db.transaction(() => {
|
||||
// Update password
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('password', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(JSON.stringify(hashedPassword));
|
||||
|
||||
// Set passwordLoginAllowed to true
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('passwordLoginAllowed', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(JSON.stringify(true));
|
||||
|
||||
// Set loginEnabled to true
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('loginEnabled', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(JSON.stringify(true));
|
||||
})();
|
||||
|
||||
console.log("✓ Password reset successfully");
|
||||
console.log("✓ Password login enabled");
|
||||
console.log("✓ Login enabled");
|
||||
|
||||
if (isGenerated) {
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("NEW PASSWORD (save this securely):");
|
||||
console.log(password);
|
||||
console.log("=".repeat(50));
|
||||
console.log("\n⚠️ This password will not be shown again!");
|
||||
} else {
|
||||
console.log("\n✓ Password has been set to the provided value");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating database:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const providedPassword = args[0];
|
||||
|
||||
// Run the script
|
||||
resetPassword(providedPassword).catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe('passwordController', () => {
|
||||
mockRes = {
|
||||
json: jsonMock,
|
||||
status: statusMock,
|
||||
cookie: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -39,12 +40,16 @@ describe('passwordController', () => {
|
||||
describe('verifyPassword', () => {
|
||||
it('should return success: true if verified', async () => {
|
||||
mockReq.body = { password: 'pass' };
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({ success: true });
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({
|
||||
success: true,
|
||||
token: 'mock-token',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
await passwordController.verifyPassword(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(passwordService.verifyPassword).toHaveBeenCalledWith('pass');
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ success: true });
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ success: true, role: 'admin' });
|
||||
});
|
||||
|
||||
it('should return 401 if incorrect', async () => {
|
||||
@@ -57,10 +62,8 @@ describe('passwordController', () => {
|
||||
|
||||
await passwordController.verifyPassword(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Incorrect'
|
||||
success: false
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -74,7 +77,6 @@ describe('passwordController', () => {
|
||||
|
||||
await passwordController.verifyPassword(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(429);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: false,
|
||||
waitTime: 60
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('SettingsController', () => {
|
||||
res = {
|
||||
json,
|
||||
status,
|
||||
cookie: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -95,12 +96,16 @@ describe('SettingsController', () => {
|
||||
it('should verify correct password', async () => {
|
||||
req.body = { password: 'pass' };
|
||||
const passwordService = await import('../../services/passwordService');
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({ success: true });
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({
|
||||
success: true,
|
||||
token: 'mock-token',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
|
||||
expect(passwordService.verifyPassword).toHaveBeenCalledWith('pass');
|
||||
expect(json).toHaveBeenCalledWith({ success: true });
|
||||
expect(json).toHaveBeenCalledWith({ success: true, role: 'admin' });
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
@@ -114,10 +119,8 @@ describe('SettingsController', () => {
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
|
||||
expect(passwordService.verifyPassword).toHaveBeenCalledWith('wrong');
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Incorrect password'
|
||||
success: false
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as videoMetadataController from '../../controllers/videoMetadataController';
|
||||
import * as metadataService from '../../services/metadataService';
|
||||
import * as storageService from '../../services/storageService';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/storageService');
|
||||
vi.mock('../../services/metadataService', () => ({
|
||||
getVideoDuration: vi.fn()
|
||||
}));
|
||||
vi.mock('../../utils/security', () => ({
|
||||
validateVideoPath: vi.fn((path) => path),
|
||||
validateImagePath: vi.fn((path) => path),
|
||||
@@ -108,4 +112,37 @@ describe('videoMetadataController', () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshThumbnail', () => {
|
||||
it('should refresh thumbnail with random timestamp', async () => {
|
||||
mockReq.params = { id: '123' };
|
||||
const mockVideo = {
|
||||
id: '123',
|
||||
videoPath: '/videos/test.mp4',
|
||||
thumbnailPath: '/images/test.jpg',
|
||||
thumbnailFilename: 'test.jpg'
|
||||
};
|
||||
(storageService.getVideoById as any).mockReturnValue(mockVideo);
|
||||
(metadataService.getVideoDuration as any).mockResolvedValue(100); // 100 seconds duration
|
||||
|
||||
await videoMetadataController.refreshThumbnail(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(storageService.getVideoById).toHaveBeenCalledWith('123');
|
||||
expect(metadataService.getVideoDuration).toHaveBeenCalled();
|
||||
|
||||
// Verify execFileSafe was called with ffmpeg
|
||||
// The exact arguments depend on the random timestamp, but we can verify the structure
|
||||
const security = await import('../../utils/security');
|
||||
expect(security.execFileSafe).toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.arrayContaining([
|
||||
'-i', expect.stringContaining('test.mp4'),
|
||||
'-ss', expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/),
|
||||
'-vframes', '1',
|
||||
expect.stringContaining('test.jpg'),
|
||||
'-y'
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { visitorModeMiddleware } from '../../middleware/visitorModeMiddleware';
|
||||
import * as storageService from '../../services/storageService';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/storageService');
|
||||
|
||||
describe('visitorModeMiddleware', () => {
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let next: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReq = {
|
||||
method: 'GET',
|
||||
body: {},
|
||||
path: '/api/something',
|
||||
url: '/api/something'
|
||||
};
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn()
|
||||
};
|
||||
next = vi.fn();
|
||||
});
|
||||
|
||||
it('should call next if visitor mode disabled', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: false });
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow GET requests in visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.method = 'GET';
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block POST requests unless disabling visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.method = 'POST';
|
||||
mockReq.body = { someSetting: true };
|
||||
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should allow disabling visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.method = 'POST';
|
||||
mockReq.body = { visitorMode: false };
|
||||
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { visitorModeSettingsMiddleware } from '../../middleware/visitorModeSettingsMiddleware';
|
||||
import * as storageService from '../../services/storageService';
|
||||
|
||||
vi.mock('../../services/storageService');
|
||||
|
||||
describe('visitorModeSettingsMiddleware', () => {
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let next: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
path: '/api/settings',
|
||||
url: '/api/settings'
|
||||
};
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn()
|
||||
};
|
||||
next = vi.fn();
|
||||
});
|
||||
|
||||
it('should allow cloudflare updates in visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.body = { cloudflaredTunnelEnabled: true };
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block other updates', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.body = { websiteName: 'Hacked' };
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MissAVDownloader } from '../../../services/downloaders/MissAVDownloader';
|
||||
|
||||
describe('MissAVDownloader URL Selection', () => {
|
||||
describe('selectBestM3u8Url', () => {
|
||||
it('should prioritize surrit.com master playlist over other specific quality playlists', () => {
|
||||
const urls = [
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8',
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/480p/video.m3u8',
|
||||
'https://edge-hls.growcdnssedge.com/hls/121964773/master/121964773_240p.m3u8',
|
||||
'https://media-hls.growcdnssedge.com/b-hls-18/121964773/121964773_240p.m3u8'
|
||||
];
|
||||
|
||||
// Default behavior (no format sort)
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
expect(selected).toBe('https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8');
|
||||
});
|
||||
|
||||
it('should prioritize higher resolution when multiple surrit URLs exist', () => {
|
||||
const urls = [
|
||||
'https://surrit.com/uuid/playlist.m3u8', // Master
|
||||
'https://surrit.com/uuid/720p/video.m3u8',
|
||||
'https://surrit.com/uuid/480p/video.m3u8'
|
||||
];
|
||||
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
// If we have specific qualities, we usually prefer the highest specific one if no format sort is used,
|
||||
// OR we might prefer the master if we trust yt-dlp to pick best.
|
||||
// Based on typical behavior without format sort: existing logic preferred specific resolutions.
|
||||
// But for MissAV, playlist.m3u8 is usually more reliable/complete.
|
||||
// Let's assume we want to stick with Master if available for surrit.
|
||||
expect(selected).toContain('playlist.m3u8');
|
||||
// OR if we keep logic "prefer specific quality", then 720p.
|
||||
// The requirement is "Prioritize surrit.com URLs... prefer playlist.m3u8 (generic master) over specific resolution masters if the specific resolution is low/suspicious"
|
||||
// In this case 720p is good.
|
||||
// However, usually playlist.m3u8 contains all variants.
|
||||
});
|
||||
|
||||
it('should fallback to resolution comparison if no surrit URLs', () => {
|
||||
const urls = [
|
||||
'https://other.com/video_240p.m3u8',
|
||||
'https://other.com/video_720p.m3u8',
|
||||
'https://other.com/video_480p.m3u8'
|
||||
];
|
||||
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
expect(selected).toBe('https://other.com/video_720p.m3u8');
|
||||
});
|
||||
|
||||
it('should handle real world scenario from logs', () => {
|
||||
// From user log
|
||||
const urls = [
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8',
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/480p/video.m3u8',
|
||||
'https://media-hls.growcdnssedge.com/b-hls-18/121964773/121964773_240p.m3u8',
|
||||
'https://edge-hls.growcdnssedge.com/hls/121964773/master/121964773_240p.m3u8'
|
||||
];
|
||||
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
// The bug was it picked the last one (edge-hls...240p.m3u8) or similar.
|
||||
// We want the surrit playlist.
|
||||
expect(selected).toBe('https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8');
|
||||
});
|
||||
|
||||
it('should respect format sort when enabled', () => {
|
||||
const urls = [
|
||||
'https://surrit.com/uuid/playlist.m3u8',
|
||||
'https://surrit.com/uuid/480p/video.m3u8'
|
||||
];
|
||||
// With format sort, we DEFINITELY want the master playlist so yt-dlp can do the sorting
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, true);
|
||||
expect(selected).toBe('https://surrit.com/uuid/playlist.m3u8');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ vi.mock('../../../services/storageService', () => ({
|
||||
saveVideo: vi.fn(),
|
||||
getVideoBySourceUrl: vi.fn(),
|
||||
updateVideo: vi.fn(),
|
||||
getSettings: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Mock fs-extra - define mockWriter inside the factory
|
||||
|
||||
168
backend/src/__tests__/services/downloaders/file_location.test.ts
Normal file
168
backend/src/__tests__/services/downloaders/file_location.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available for vi.mock factory
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
executeYtDlpSpawn: vi.fn(),
|
||||
executeYtDlpJson: vi.fn(),
|
||||
getUserYtDlpConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Setup default return values in the factory or beforeEach
|
||||
mocks.executeYtDlpJson.mockResolvedValue({
|
||||
title: 'Test Video',
|
||||
uploader: 'Test Author',
|
||||
upload_date: '20230101',
|
||||
thumbnail: 'http://example.com/thumb.jpg',
|
||||
extractor: 'youtube'
|
||||
});
|
||||
mocks.getUserYtDlpConfig.mockReturnValue({});
|
||||
mocks.getSettings.mockReturnValue({});
|
||||
mocks.readdirSync.mockReturnValue([]);
|
||||
mocks.readFileSync.mockReturnValue('WEBVTT');
|
||||
|
||||
vi.mock('../../../config/paths', () => ({
|
||||
VIDEOS_DIR: '/mock/videos',
|
||||
IMAGES_DIR: '/mock/images',
|
||||
SUBTITLES_DIR: '/mock/subtitles',
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/ytDlpUtils', () => ({
|
||||
executeYtDlpSpawn: (...args: any[]) => mocks.executeYtDlpSpawn(...args),
|
||||
executeYtDlpJson: (...args: any[]) => mocks.executeYtDlpJson(...args),
|
||||
getUserYtDlpConfig: (...args: any[]) => mocks.getUserYtDlpConfig(...args),
|
||||
getNetworkConfigFromUserConfig: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/storageService', () => ({
|
||||
updateActiveDownload: vi.fn(),
|
||||
saveVideo: vi.fn(),
|
||||
getVideoBySourceUrl: vi.fn(),
|
||||
updateVideo: vi.fn(),
|
||||
getSettings: () => mocks.getSettings(),
|
||||
}));
|
||||
|
||||
// Mock processSubtitles to verify it receives correct arguments
|
||||
// We need to access the actual implementation in logic but for this test checking arguments might be enough
|
||||
// However, the real test is seeing if paths are correct in downloadVideo
|
||||
// And we want to test processSubtitles logic too.
|
||||
|
||||
// Let's mock fs-extra completely
|
||||
vi.mock('fs-extra', () => {
|
||||
return {
|
||||
default: {
|
||||
pathExists: vi.fn().mockResolvedValue(false),
|
||||
ensureDirSync: vi.fn(),
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
createWriteStream: vi.fn().mockReturnValue({
|
||||
on: (event: string, cb: any) => {
|
||||
if (event === 'finish') cb();
|
||||
return { on: vi.fn() };
|
||||
}
|
||||
}),
|
||||
readdirSync: (...args: any[]) => mocks.readdirSync(...args),
|
||||
readFileSync: (...args: any[]) => mocks.readFileSync(...args),
|
||||
writeFileSync: (...args: any[]) => mocks.writeFileSync(...args),
|
||||
copyFileSync: vi.fn(),
|
||||
unlinkSync: (...args: any[]) => mocks.unlinkSync(...args),
|
||||
remove: (...args: any[]) => mocks.remove(...args),
|
||||
statSync: vi.fn().mockReturnValue({ size: 1000 }),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
pipe: (writer: any) => {
|
||||
// Simulate write finish if writer has on method
|
||||
if (writer.on) {
|
||||
// Find and call finish handler manually if needed
|
||||
// But strictly relying on the createWriteStream mock above handling it
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/metadataService', () => ({
|
||||
getVideoDuration: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/downloadUtils', () => ({
|
||||
isDownloadActive: vi.fn().mockReturnValue(true), // Always active
|
||||
isCancellationError: vi.fn().mockReturnValue(false),
|
||||
cleanupSubtitleFiles: vi.fn(),
|
||||
cleanupVideoArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the modules under test
|
||||
import { processSubtitles } from '../../../services/downloaders/ytdlp/ytdlpSubtitle';
|
||||
|
||||
describe('File Location Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.executeYtDlpSpawn.mockReturnValue({
|
||||
stdout: { on: vi.fn() },
|
||||
kill: vi.fn(),
|
||||
then: (resolve: any) => resolve()
|
||||
});
|
||||
mocks.readdirSync.mockReturnValue([]);
|
||||
// Reset default mock implementations if needed, but they are set on the object so clearer to set logic in test
|
||||
});
|
||||
|
||||
// describe('downloadVideo', () => {});
|
||||
|
||||
describe('processSubtitles', () => {
|
||||
it('should move subtitles to SUBTITLES_DIR by default', async () => {
|
||||
const baseFilename = 'video_123';
|
||||
mocks.readdirSync.mockReturnValue(['video_123.en.vtt']);
|
||||
mocks.readFileSync.mockReturnValue('WEBVTT');
|
||||
|
||||
await processSubtitles(baseFilename, 'download_id', false);
|
||||
|
||||
expect(mocks.writeFileSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/subtitles', 'video_123.en.vtt'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
expect(mocks.unlinkSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/videos', 'video_123.en.vtt')
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep subtitles in VIDEOS_DIR if moveSubtitlesToVideoFolder is true', async () => {
|
||||
const baseFilename = 'video_123';
|
||||
mocks.readdirSync.mockReturnValue(['video_123.en.vtt']);
|
||||
mocks.readFileSync.mockReturnValue('WEBVTT');
|
||||
|
||||
await processSubtitles(baseFilename, 'download_id', true);
|
||||
|
||||
// Expect destination to be VIDEOS_DIR
|
||||
expect(mocks.writeFileSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/videos', 'video_123.en.vtt'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// source and dest are technically same dir (but maybe different filenames if lang was parsed differently?)
|
||||
// In typical case: source = /videos/video_123.en.vtt, dest = /videos/video_123.en.vtt
|
||||
// Code says: if (sourceSubPath !== destSubPath) unlinkSync
|
||||
|
||||
// Using mock path.join, let's trace:
|
||||
// source = /mock/videos/video_123.en.vtt
|
||||
// dest = /mock/videos/video_123.en.vtt
|
||||
// So unlinkSync should NOT be called
|
||||
expect(mocks.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,30 +20,6 @@ describe('settingsValidationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVisitorModeRestrictions', () => {
|
||||
it('should allow everything if visitor mode disabled', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions({ visitorMode: false } as any, { websiteName: 'New' });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should block changes if visitor mode enabled', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions({ visitorMode: true } as any, { websiteName: 'New' });
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow turning off visitor mode', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions({ visitorMode: true } as any, { visitorMode: false });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow cloudflare settings update', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions(
|
||||
{ visitorMode: true } as any,
|
||||
{ cloudflaredTunnelEnabled: true }
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSettings', () => {
|
||||
it('should merge defaults, existing, and new', () => {
|
||||
|
||||
@@ -27,10 +27,20 @@ vi.mock('../../db', () => {
|
||||
const selectFromLeftJoinWhereAll = vi.fn().mockReturnValue([]);
|
||||
const selectFromLeftJoinAll = vi.fn().mockReturnValue([]);
|
||||
|
||||
const updateSetRun = vi.fn();
|
||||
const updateSet = vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
run: updateSetRun,
|
||||
}),
|
||||
});
|
||||
const updateMock = vi.fn().mockReturnValue({
|
||||
set: updateSet,
|
||||
});
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: insertFn,
|
||||
update: vi.fn(),
|
||||
update: updateMock,
|
||||
delete: deleteMock,
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
@@ -55,7 +65,7 @@ vi.mock('../../db', () => {
|
||||
sqlite: {
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
run: vi.fn(),
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
}),
|
||||
},
|
||||
downloads: {}, // Mock downloads table
|
||||
@@ -94,9 +104,16 @@ describe('StorageService', () => {
|
||||
run: vi.fn(),
|
||||
}),
|
||||
});
|
||||
(db.update as any).mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
run: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
(sqlite.prepare as any).mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
run: vi.fn(),
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -588,7 +605,16 @@ describe('StorageService', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 2. getVideoById (inside loop)
|
||||
// 2. getCollections (called before getVideoById in deleteCollectionWithFiles)
|
||||
selectSpy.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 3. getVideoById (inside loop) - called for each video in collection
|
||||
selectSpy.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
@@ -597,8 +623,14 @@ describe('StorageService', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 3. getCollections (to check other collections) - called by findVideoFile
|
||||
// Will use the default db.select mock which returns empty array
|
||||
// 4. getCollections (called by findVideoFile inside moveAllFilesFromCollection)
|
||||
selectSpy.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 4. deleteCollection (inside deleteCollectionWithFiles) -> db.delete
|
||||
(db.delete as any).mockReturnValue({
|
||||
@@ -645,7 +677,13 @@ describe('StorageService', () => {
|
||||
} as any);
|
||||
|
||||
// 3. getCollections (called by findVideoFile in deleteVideo)
|
||||
// Will use the default db.select mock which returns empty array
|
||||
selectMock.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 4. deleteVideo -> db.delete(videos)
|
||||
(db.delete as any).mockReturnValue({
|
||||
|
||||
@@ -21,6 +21,16 @@ describe('security', () => {
|
||||
it('should return false for traversal', () => {
|
||||
expect(security.validatePathWithinDirectory('/base/../other/file.txt', '/base')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle absolute paths correctly without duplication', () => {
|
||||
// Mock path.resolve to behave predictably for testing logic if needed,
|
||||
// but here we rely on the implementation fix.
|
||||
// This tests that if we pass an absolute path that is valid, it returns true.
|
||||
// The critical part is that it doesn't fail internally or double-resolve.
|
||||
const absPath = '/Users/user/project/backend/uploads/videos/test.mp4';
|
||||
const allowedDir = '/Users/user/project/backend/uploads/videos';
|
||||
expect(security.validatePathWithinDirectory(absPath, allowedDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrl', () => {
|
||||
|
||||
@@ -28,10 +28,54 @@ export const uploadHook = async (
|
||||
throw new ValidationError("Invalid hook name", "name");
|
||||
}
|
||||
|
||||
// Scan for risk commands
|
||||
const riskCommand = scanForRiskCommands(req.file.path);
|
||||
if (riskCommand) {
|
||||
// Delete the file immediately
|
||||
require("fs").unlinkSync(req.file.path);
|
||||
throw new ValidationError(
|
||||
`Risk command detected: ${riskCommand}. Upload rejected.`,
|
||||
"file"
|
||||
);
|
||||
}
|
||||
|
||||
HookService.uploadHook(name, req.file.path);
|
||||
res.json(successMessage(`Hook ${name} uploaded successfully`));
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan file for risk commands
|
||||
*/
|
||||
const scanForRiskCommands = (filePath: string): string | null => {
|
||||
const fs = require("fs");
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// List of risky patterns
|
||||
// We use regex to match commands, trying to avoid false positives in comments if possible,
|
||||
// but for safety, even commented dangerous commands might be flagged or we just accept strictness.
|
||||
// A simple include check is safer for now.
|
||||
const riskyPatterns = [
|
||||
{ pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|-[a-zA-Z]*f[a-zA-Z]*\s+)*-?[rf][a-zA-Z]*\s+.*[\/\*]/, name: "rm -rf / (recursive delete)" }, // Matches rm -rf /, rm -fr *, etc roughly
|
||||
{ pattern: /mkfs/, name: "mkfs (format disk)" },
|
||||
{ pattern: /dd\s+if=/, name: "dd (disk write)" },
|
||||
{ pattern: /:[:\(\)\{\}\s|&]+;:/, name: "fork bomb" },
|
||||
{ pattern: />\s*\/dev\/sd/, name: "write to block device" },
|
||||
{ pattern: />\s*\/dev\/nvme/, name: "write to block device" },
|
||||
{ pattern: /mv\s+.*[\s\/]+\//, name: "mv to root" }, // deeply simplified, but mv / is dangerous
|
||||
{ pattern: /chmod\s+.*777\s+\//, name: "chmod 777 root" },
|
||||
{ pattern: /wget\s+http/, name: "wget (potential malware download)" },
|
||||
{ pattern: /curl\s+http/, name: "curl (potential malware download)" },
|
||||
];
|
||||
|
||||
for (const risk of riskyPatterns) {
|
||||
if (risk.pattern.test(content)) {
|
||||
return risk.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete hook script
|
||||
*/
|
||||
|
||||
188
backend/src/controllers/passkeyController.ts
Normal file
188
backend/src/controllers/passkeyController.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Request, Response } from "express";
|
||||
import { setAuthCookie } from "../services/authService";
|
||||
import * as passkeyService from "../services/passkeyService";
|
||||
|
||||
/**
|
||||
* Get all passkeys
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getPasskeys = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const passkeys = passkeyService.getPasskeys();
|
||||
// Don't send sensitive credential data to frontend
|
||||
const safePasskeys = passkeys.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
createdAt: p.createdAt,
|
||||
}));
|
||||
res.json({ passkeys: safePasskeys });
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if passkeys exist
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const checkPasskeysExist = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const passkeys = passkeyService.getPasskeys();
|
||||
res.json({ exists: passkeys.length > 0 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get origin and RP ID from request
|
||||
*/
|
||||
function getOriginAndRPID(req: Request): { origin: string; rpID: string } {
|
||||
// Get origin from headers
|
||||
let origin = req.headers.origin;
|
||||
if (!origin && req.headers.referer) {
|
||||
// Extract origin from referer
|
||||
try {
|
||||
const refererUrl = new URL(req.headers.referer);
|
||||
origin = refererUrl.origin;
|
||||
} catch (e) {
|
||||
origin = req.headers.referer;
|
||||
}
|
||||
}
|
||||
if (!origin) {
|
||||
const protocol =
|
||||
req.headers["x-forwarded-proto"] || (req.secure ? "https" : "http");
|
||||
const host = req.headers.host || "localhost:5550";
|
||||
origin = `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
// Extract hostname for RP_ID
|
||||
let hostname = "localhost";
|
||||
try {
|
||||
const originUrl = new URL(origin as string);
|
||||
hostname = originUrl.hostname;
|
||||
} catch (e) {
|
||||
// Fallback: extract from host header
|
||||
hostname = req.headers.host?.split(":")[0] || "localhost";
|
||||
}
|
||||
|
||||
// RP_ID should be the domain name (without port)
|
||||
// For localhost/127.0.0.1, use 'localhost', otherwise use the full hostname
|
||||
const rpID =
|
||||
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
|
||||
? "localhost"
|
||||
: hostname;
|
||||
|
||||
return { origin: origin as string, rpID };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate registration options for creating a new passkey
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const generateRegistrationOptions = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const userName = req.body.userName || "MyTube User";
|
||||
const { origin, rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.generatePasskeyRegistrationOptions(
|
||||
userName,
|
||||
origin,
|
||||
rpID
|
||||
);
|
||||
res.json(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify and store a new passkey
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyRegistration = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { body, challenge } = req.body;
|
||||
if (!body || !challenge) {
|
||||
res.status(400).json({ error: "Missing body or challenge" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { origin, rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.verifyPasskeyRegistration(
|
||||
body,
|
||||
challenge,
|
||||
origin,
|
||||
rpID
|
||||
);
|
||||
|
||||
if (result.verified) {
|
||||
res.json({ success: true, passkey: result.passkey });
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: "Verification failed" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate authentication options for passkey login
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const generateAuthenticationOptions = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.generatePasskeyAuthenticationOptions(
|
||||
rpID
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: error instanceof Error ? error.message : "No passkeys available",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyAuthentication = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { body, challenge } = req.body;
|
||||
if (!body || !challenge) {
|
||||
res.status(400).json({ error: "Missing body or challenge" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { origin, rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.verifyPasskeyAuthentication(
|
||||
body,
|
||||
challenge,
|
||||
origin,
|
||||
rpID
|
||||
);
|
||||
|
||||
if (result.verified && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({ success: true, role: result.role });
|
||||
} else {
|
||||
res.status(401).json({ success: false, error: "Authentication failed" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove all passkeys
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const removeAllPasskeys = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
passkeyService.removeAllPasskeys();
|
||||
res.json({ success: true });
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from "express";
|
||||
import { clearAuthCookie, setAuthCookie } from "../services/authService";
|
||||
import * as passwordService from "../services/passwordService";
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,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 (
|
||||
@@ -26,20 +28,111 @@ export const verifyPassword = async (
|
||||
|
||||
const result = await passwordService.verifyPassword(password);
|
||||
|
||||
if (result.success) {
|
||||
// Return format expected by frontend: { success: boolean }
|
||||
res.json({ success: true });
|
||||
if (result.success && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role
|
||||
});
|
||||
} else {
|
||||
// Return wait time information
|
||||
res.status(result.waitTime ? 429 : 401).json({
|
||||
// Return 200 OK to suppress browser console errors, but include status code and success: false
|
||||
const statusCode = result.waitTime ? 429 : 401;
|
||||
res.json({
|
||||
success: false,
|
||||
waitTime: result.waitTime,
|
||||
failedAttempts: result.failedAttempts,
|
||||
message: result.message,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role
|
||||
});
|
||||
} 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 && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role
|
||||
});
|
||||
} 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
|
||||
*/
|
||||
export const getResetPasswordCooldown = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const remainingCooldown = passwordService.getResetPasswordCooldown();
|
||||
res.json({
|
||||
cooldown: remainingCooldown,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset password to a random 8-character string
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
@@ -57,3 +150,15 @@ export const resetPassword = async (
|
||||
"Password has been reset. Check backend logs for the new password.",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout endpoint - clears authentication cookies
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const logout = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
clearAuthCookie(res);
|
||||
res.json({ success: true, message: "Logged out successfully" });
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import {
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
} from "../config/paths";
|
||||
import { cloudflaredService } from "../services/cloudflaredService";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
@@ -37,9 +37,9 @@ export const getSettings = async (
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Do not send the hashed password to the frontend
|
||||
const { password, ...safeSettings } = mergedSettings;
|
||||
const { password, visitorPassword, ...safeSettings } = mergedSettings;
|
||||
// Return data directly for backward compatibility
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password });
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password, isVisitorPasswordSet: !!visitorPassword });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,35 +124,9 @@ export const updateSettings = async (
|
||||
{}
|
||||
);
|
||||
|
||||
// Check visitor mode restrictions
|
||||
const visitorModeCheck =
|
||||
settingsValidationService.checkVisitorModeRestrictions(
|
||||
mergedSettings,
|
||||
newSettings
|
||||
);
|
||||
// Permission control is now handled by roleBasedSettingsMiddleware
|
||||
|
||||
if (!visitorModeCheck.allowed) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: visitorModeCheck.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special case: visitorMode being set to true (already enabled)
|
||||
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 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
settingsValidationService.validateSettings(newSettings);
|
||||
@@ -253,7 +227,7 @@ export const updateSettings = async (
|
||||
// Return format expected by frontend: { success: true, settings: {...} }
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...finalSettings, password: undefined },
|
||||
settings: { ...finalSettings, password: undefined, visitorPassword: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||
import { getVideoDuration } from "../services/metadataService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { successResponse } from "../utils/response";
|
||||
@@ -95,11 +96,31 @@ export const refreshThumbnail = async (
|
||||
const validatedThumbnailPath = validateImagePath(thumbnailAbsolutePath);
|
||||
fs.ensureDirSync(path.dirname(validatedThumbnailPath));
|
||||
|
||||
// Calculate random timestamp
|
||||
let timestamp = "00:00:00";
|
||||
try {
|
||||
const duration = await getVideoDuration(validatedVideoPath);
|
||||
if (duration && duration > 0) {
|
||||
// Pick a random second, avoiding the very beginning and very end if possible
|
||||
// But for simplicity and to match request "random frame", valid random second is fine.
|
||||
// Let's ensure we don't go past the end.
|
||||
const randomSecond = Math.floor(Math.random() * duration);
|
||||
const hours = Math.floor(randomSecond / 3600);
|
||||
const minutes = Math.floor((randomSecond % 3600) / 60);
|
||||
const seconds = randomSecond % 60;
|
||||
timestamp = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to get video duration for random thumbnail, using default 00:00:00", err);
|
||||
}
|
||||
|
||||
// Generate thumbnail using execFileSafe to prevent command injection
|
||||
try {
|
||||
await execFileSafe("ffmpeg", [
|
||||
"-i", validatedVideoPath,
|
||||
"-ss", "00:00:00",
|
||||
"-ss", timestamp,
|
||||
"-vframes", "1",
|
||||
validatedThumbnailPath,
|
||||
"-y"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
49
backend/src/middleware/authMiddleware.ts
Normal file
49
backend/src/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { getAuthCookieName, UserPayload, verifyToken } from "../services/authService";
|
||||
|
||||
// Extend Express Request type to include user property
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token and attach user to request
|
||||
* Checks both HTTP-only cookies (preferred) and Authorization header (for backward compatibility)
|
||||
* Does NOT block requests if token is missing/invalid, just leaves req.user undefined
|
||||
* Blocking logic should be handled by specific route guards or role-based middleware
|
||||
*/
|
||||
export const authMiddleware = (
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
// First, try to get token from HTTP-only cookie (preferred method)
|
||||
const cookieName = getAuthCookieName();
|
||||
const tokenFromCookie = req.cookies?.[cookieName];
|
||||
|
||||
if (tokenFromCookie) {
|
||||
const decoded = verifyToken(tokenFromCookie);
|
||||
if (decoded) {
|
||||
req.user = decoded;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Authorization header for backward compatibility
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (decoded) {
|
||||
req.user = decoded;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
64
backend/src/middleware/roleBasedAuthMiddleware.ts
Normal file
64
backend/src/middleware/roleBasedAuthMiddleware.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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 (including verify-admin-password and verify-visitor-password)
|
||||
if (
|
||||
req.path.includes("/verify-password") ||
|
||||
req.url.includes("/verify-password") ||
|
||||
req.path.includes("/verify-admin-password") ||
|
||||
req.url.includes("/verify-admin-password") ||
|
||||
req.path.includes("/verify-visitor-password") ||
|
||||
req.url.includes("/verify-visitor-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();
|
||||
};
|
||||
98
backend/src/middleware/roleBasedSettingsMiddleware.ts
Normal file
98
backend/src/middleware/roleBasedSettingsMiddleware.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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 (including verify-admin-password and verify-visitor-password)
|
||||
if (
|
||||
req.path.includes("/verify-password") ||
|
||||
req.url.includes("/verify-password") ||
|
||||
req.path.includes("/verify-admin-password") ||
|
||||
req.url.includes("/verify-admin-password") ||
|
||||
req.path.includes("/verify-visitor-password") ||
|
||||
req.url.includes("/verify-visitor-password")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow passkey authentication
|
||||
if (
|
||||
req.path.includes("/passkeys/authenticate") ||
|
||||
req.url.includes("/passkeys/authenticate")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow logout endpoint
|
||||
if (
|
||||
req.path.includes("/logout") ||
|
||||
req.url.includes("/logout")
|
||||
) {
|
||||
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();
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Middleware to block write operations when visitor mode is enabled
|
||||
* Only allows disabling visitor mode (POST /settings with visitorMode: false)
|
||||
*/
|
||||
export const visitorModeMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const settings = storageService.getSettings();
|
||||
const visitorMode = settings.visitorMode === true;
|
||||
|
||||
if (!visitorMode) {
|
||||
// Visitor mode is not enabled, allow all requests
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the request is trying to disable visitor mode or verify password
|
||||
if (req.method === "POST") {
|
||||
const body = req.body || {};
|
||||
|
||||
// Allow verify-password requests
|
||||
// Check path for verify-password (assuming mounted on /api or similar)
|
||||
if (req.path.includes("/verify-password") || req.url.includes("/verify-password")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Write operations are not allowed.",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Middleware specifically for settings routes
|
||||
* Allows disabling visitor mode even when visitor mode is enabled
|
||||
*/
|
||||
export const visitorModeSettingsMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const settings = storageService.getSettings();
|
||||
const visitorMode = settings.visitorMode === true;
|
||||
|
||||
if (!visitorMode) {
|
||||
// Visitor mode is not enabled, allow all requests
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// For POST requests, check if it's trying to disable visitor mode, verify password, or update CloudFlare settings
|
||||
if (req.method === "POST") {
|
||||
// Allow verify-password requests
|
||||
if (
|
||||
req.path.includes("/verify-password") ||
|
||||
req.url.includes("/verify-password")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow CloudFlare tunnel settings updates (read-only access mechanism, doesn't violate visitor mode)
|
||||
const isOnlyCloudflareUpdate =
|
||||
(body.cloudflaredTunnelEnabled !== undefined ||
|
||||
body.cloudflaredToken !== undefined) &&
|
||||
Object.keys(body).every(
|
||||
(key) =>
|
||||
key === "cloudflaredTunnelEnabled" ||
|
||||
key === "cloudflaredToken" ||
|
||||
key === "visitorMode"
|
||||
);
|
||||
|
||||
if (isOnlyCloudflareUpdate) {
|
||||
// Allow CloudFlare settings updates even in visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
"Visitor mode is enabled. Only disabling visitor mode or updating CloudFlare settings is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Write operations are not allowed.",
|
||||
});
|
||||
};
|
||||
@@ -2,32 +2,47 @@ import express from "express";
|
||||
import multer from "multer";
|
||||
import os from "os";
|
||||
import {
|
||||
checkCookies,
|
||||
deleteCookies,
|
||||
uploadCookies,
|
||||
checkCookies,
|
||||
deleteCookies,
|
||||
uploadCookies,
|
||||
} from "../controllers/cookieController";
|
||||
import {
|
||||
cleanupBackupDatabases,
|
||||
exportDatabase,
|
||||
getLastBackupInfo,
|
||||
importDatabase,
|
||||
restoreFromLastBackup,
|
||||
cleanupBackupDatabases,
|
||||
exportDatabase,
|
||||
getLastBackupInfo,
|
||||
importDatabase,
|
||||
restoreFromLastBackup,
|
||||
} from "../controllers/databaseBackupController";
|
||||
import {
|
||||
deleteHook,
|
||||
getHookStatus,
|
||||
uploadHook,
|
||||
deleteHook,
|
||||
getHookStatus,
|
||||
uploadHook,
|
||||
} from "../controllers/hookController";
|
||||
import {
|
||||
getPasswordEnabled
|
||||
getPasswordEnabled,
|
||||
getResetPasswordCooldown,
|
||||
logout,
|
||||
resetPassword,
|
||||
verifyPassword,
|
||||
verifyAdminPassword,
|
||||
verifyVisitorPassword,
|
||||
} from "../controllers/passwordController";
|
||||
import {
|
||||
deleteLegacyData,
|
||||
formatFilenames,
|
||||
getCloudflaredStatus,
|
||||
getSettings,
|
||||
migrateData,
|
||||
updateSettings,
|
||||
checkPasskeysExist,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
getPasskeys,
|
||||
removeAllPasskeys,
|
||||
verifyAuthentication,
|
||||
verifyRegistration,
|
||||
} from "../controllers/passkeyController";
|
||||
import {
|
||||
deleteLegacyData,
|
||||
formatFilenames,
|
||||
getCloudflaredStatus,
|
||||
getSettings,
|
||||
migrateData,
|
||||
updateSettings,
|
||||
} from "../controllers/settingsController";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
|
||||
@@ -43,6 +58,21 @@ 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)); // 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));
|
||||
router.post("/logout", asyncHandler(logout));
|
||||
|
||||
// Passkey routes
|
||||
router.get("/passkeys", asyncHandler(getPasskeys));
|
||||
router.get("/passkeys/exists", asyncHandler(checkPasskeysExist));
|
||||
router.post("/passkeys/register", asyncHandler(generateRegistrationOptions));
|
||||
router.post("/passkeys/register/verify", asyncHandler(verifyRegistration));
|
||||
router.post("/passkeys/authenticate", asyncHandler(generateAuthenticationOptions));
|
||||
router.post("/passkeys/authenticate/verify", asyncHandler(verifyAuthentication));
|
||||
router.delete("/passkeys", asyncHandler(removeAllPasskeys));
|
||||
|
||||
// ... existing imports ...
|
||||
|
||||
@@ -56,11 +86,7 @@ router.post("/delete-cookies", asyncHandler(deleteCookies));
|
||||
router.get("/check-cookies", asyncHandler(checkCookies));
|
||||
|
||||
// Hook routes
|
||||
router.post(
|
||||
"/hooks/:name",
|
||||
upload.single("file"),
|
||||
asyncHandler(uploadHook)
|
||||
);
|
||||
router.post("/hooks/:name", upload.single("file"), asyncHandler(uploadHook));
|
||||
router.delete("/hooks/:name", asyncHandler(deleteHook));
|
||||
router.get("/hooks/status", asyncHandler(getHookStatus));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
@@ -12,8 +13,9 @@ import {
|
||||
VIDEOS_DIR,
|
||||
} from "./config/paths";
|
||||
import { runMigrations } from "./db/migrate";
|
||||
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
|
||||
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
|
||||
import { authMiddleware } from "./middleware/authMiddleware";
|
||||
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";
|
||||
@@ -38,7 +40,13 @@ const PORT = process.env.PORT || 5551;
|
||||
app.disable("x-powered-by");
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
// Configure CORS to allow credentials for HTTP-only cookies
|
||||
app.use(cors({
|
||||
origin: true, // Allow requests from any origin (can be restricted in production)
|
||||
credentials: true, // Required for HTTP-only cookies
|
||||
}));
|
||||
// Parse cookies
|
||||
app.use(cookieParser());
|
||||
// Increase body size limits for large file uploads (10GB)
|
||||
app.use(express.json({ limit: "100gb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "100gb" }));
|
||||
@@ -243,10 +251,12 @@ const startServer = async () => {
|
||||
);
|
||||
|
||||
// API Routes
|
||||
// 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 auth middleware to all API routes
|
||||
app.use("/api", authMiddleware);
|
||||
// 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) => {
|
||||
|
||||
295
backend/src/services/__tests__/passkeyService.test.ts
Normal file
295
backend/src/services/__tests__/passkeyService.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
generatePasskeyAuthenticationOptions,
|
||||
generatePasskeyRegistrationOptions,
|
||||
removeAllPasskeys,
|
||||
verifyPasskeyAuthentication,
|
||||
verifyPasskeyRegistration
|
||||
} from "../passkeyService";
|
||||
import * as storageService from "../storageService";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../storageService", () => ({
|
||||
getSettings: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateRegistrationOptions: vi.fn(),
|
||||
verifyRegistrationResponse: vi.fn(),
|
||||
generateAuthenticationOptions: vi.fn(),
|
||||
verifyAuthenticationResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../authService", () => ({
|
||||
generateToken: vi.fn(() => "mock-token"),
|
||||
}));
|
||||
|
||||
describe("passkeyService", () => {
|
||||
const mockPasskey = {
|
||||
credentialID: "mock-credential-id",
|
||||
credentialPublicKey: "mock-public-key",
|
||||
counter: 0,
|
||||
transports: ["internal"],
|
||||
id: "mock-credential-id",
|
||||
name: "Passkey 1",
|
||||
createdAt: "2023-01-01T00:00:00.000Z",
|
||||
rpID: "localhost",
|
||||
origin: "http://localhost:5550",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
(storageService.getSettings as any).mockReturnValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("generatePasskeyRegistrationOptions", () => {
|
||||
it("should generate registration options correctly", async () => {
|
||||
const mockOptions = { challenge: "mock-challenge" };
|
||||
(generateRegistrationOptions as any).mockResolvedValue(mockOptions);
|
||||
|
||||
const result = await generatePasskeyRegistrationOptions("testuser");
|
||||
|
||||
expect(generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userName: "testuser",
|
||||
attestationType: "none",
|
||||
authenticatorSelection: expect.objectContaining({
|
||||
authenticatorAttachment: "platform",
|
||||
userVerification: "preferred",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
options: mockOptions,
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
});
|
||||
|
||||
it("should exclude existing credentials", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
(generateRegistrationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
await generatePasskeyRegistrationOptions("testuser");
|
||||
|
||||
expect(generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
excludeCredentials: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String), // In the real code it's base64url encoded
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPasskeyRegistration", () => {
|
||||
it("should verify and store a new passkey correctly (NO double encoding)", async () => {
|
||||
const mockVerification = {
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: "raw-credential-id-from-browser", // Assume simplewebauthn returns this as string/base64url
|
||||
publicKey: Buffer.from("mock-public-key"),
|
||||
counter: 0,
|
||||
transports: ["internal"],
|
||||
},
|
||||
},
|
||||
};
|
||||
(verifyRegistrationResponse as any).mockResolvedValue(mockVerification);
|
||||
|
||||
const result = await verifyPasskeyRegistration(
|
||||
{ response: {}, name: "My Passkey" },
|
||||
"mock-challenge"
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.passkey?.credentialID).toBe("raw-credential-id-from-browser"); // MUST NOT BE DOUBLE ENCODED
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "raw-credential-id-from-browser",
|
||||
name: "My Passkey",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle verification failure", async () => {
|
||||
(verifyRegistrationResponse as any).mockResolvedValue({ verified: false });
|
||||
|
||||
const result = await verifyPasskeyRegistration({}, "mock-challenge");
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(storageService.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePasskeyAuthenticationOptions", () => {
|
||||
it("should generate authentication options with correct allowCredentials (NO double encoding)", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
(generateAuthenticationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
const result = await generatePasskeyAuthenticationOptions("localhost");
|
||||
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowCredentials: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "mock-credential-id", // MUST MATCH STORED ID EXACTLY
|
||||
transports: ["internal"],
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
options: { challenge: "mock-challenge" },
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter passkeys by RP ID", async () => {
|
||||
const passkey1 = { ...mockPasskey, rpID: "domain1.com", id: "id1", credentialID: "id1" };
|
||||
const passkey2 = { ...mockPasskey, rpID: "domain2.com", id: "id2", credentialID: "id2" };
|
||||
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [passkey1, passkey2],
|
||||
});
|
||||
(generateAuthenticationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
await generatePasskeyAuthenticationOptions("domain1.com");
|
||||
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowCredentials: [
|
||||
expect.objectContaining({ id: "id1" })
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should include legacy passkeys (no rpID stored) as fallback", async () => {
|
||||
const legacyPasskey = { ...mockPasskey, rpID: undefined, id: "legacy", credentialID: "legacy" };
|
||||
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [legacyPasskey],
|
||||
});
|
||||
(generateAuthenticationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
await generatePasskeyAuthenticationOptions("any-domain.com");
|
||||
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowCredentials: [
|
||||
expect.objectContaining({ id: "legacy" })
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if no passkeys registered", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({});
|
||||
await expect(generatePasskeyAuthenticationOptions()).rejects.toThrow(
|
||||
"No passkeys registered"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPasskeyAuthentication", () => {
|
||||
it("should verify authentication successfully", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
const mockVerification = {
|
||||
verified: true,
|
||||
authenticationInfo: { newCounter: 1 },
|
||||
};
|
||||
(verifyAuthenticationResponse as any).mockResolvedValue(mockVerification);
|
||||
|
||||
const result = await verifyPasskeyAuthentication(
|
||||
{ id: "mock-credential-id", response: {} },
|
||||
"mock-challenge"
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "mock-credential-id",
|
||||
counter: 1
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "mock-credential-id",
|
||||
counter: 1
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(result.token).toBe("mock-token");
|
||||
expect(result.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("should fail if passkey not found", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
|
||||
const result = await verifyPasskeyAuthentication(
|
||||
{ id: "unknown-id", response: {} },
|
||||
"mock-challenge"
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllPasskeys", () => {
|
||||
it("should remove all passkeys", () => {
|
||||
removeAllPasskeys();
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith({
|
||||
passkeys: []
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
92
backend/src/services/authService.ts
Normal file
92
backend/src/services/authService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "default_development_secret_do_not_use_in_production";
|
||||
const JWT_EXPIRES_IN = "24h";
|
||||
const COOKIE_NAME = "mytube_auth_token";
|
||||
|
||||
export interface UserPayload {
|
||||
role: "admin" | "visitor";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a user
|
||||
*/
|
||||
export const generateToken = (payload: UserPayload): string => {
|
||||
return jwt.sign({ ...payload, id: payload.id || uuidv4() }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export const verifyToken = (token: string): UserPayload | null => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set HTTP-only cookie with authentication token
|
||||
* This is more secure than storing tokens in localStorage as it's not accessible to JavaScript
|
||||
*/
|
||||
export const setAuthCookie = (res: Response, token: string, role: "admin" | "visitor"): void => {
|
||||
// Calculate expiration time (24 hours in milliseconds)
|
||||
const maxAge = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Set HTTP-only cookie (not accessible to JavaScript, preventing XSS attacks)
|
||||
// SameSite=Strict provides CSRF protection
|
||||
// Secure flag should be set in production (HTTPS only)
|
||||
// Set HTTP-only cookie (not accessible to JavaScript, preventing XSS attacks)
|
||||
// SameSite=Lax allows for better usability while maintaining CSRF protection
|
||||
// Secure flag is optional (env var) to allow potential HTTP usage in private networks
|
||||
const isSecure = process.env.SECURE_COOKIES === "true";
|
||||
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true, // Not accessible to JavaScript
|
||||
secure: isSecure, // Only sent over HTTPS if explicitly configured
|
||||
sameSite: "lax", // Better persistence across navigations
|
||||
maxAge: maxAge, // 24 hours
|
||||
path: "/", // Available for all paths
|
||||
});
|
||||
|
||||
// Also set role in a separate cookie (non-HTTP-only for frontend to read)
|
||||
res.cookie("mytube_role", role, {
|
||||
httpOnly: false, // Frontend needs to read this
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear authentication cookies
|
||||
*/
|
||||
export const clearAuthCookie = (res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: process.env.SECURE_COOKIES === "true",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
res.clearCookie("mytube_role", {
|
||||
httpOnly: false,
|
||||
secure: process.env.SECURE_COOKIES === "true",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cookie name for authentication token
|
||||
*/
|
||||
export const getAuthCookieName = (): string => {
|
||||
return COOKIE_NAME;
|
||||
};
|
||||
@@ -3,8 +3,8 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { DownloadCancelledError } from "../../errors/DownloadErrors";
|
||||
import {
|
||||
isCancellationError,
|
||||
isDownloadActive,
|
||||
isCancellationError,
|
||||
isDownloadActive,
|
||||
} from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
@@ -41,15 +41,22 @@ export abstract class BaseDownloader implements IDownloader {
|
||||
options?: DownloadOptions
|
||||
): Promise<Video>;
|
||||
|
||||
// Default timeout for thumbnail downloads (60 seconds)
|
||||
protected static readonly THUMBNAIL_DOWNLOAD_TIMEOUT = 60000;
|
||||
|
||||
/**
|
||||
* Common helper to download a thumbnail
|
||||
*/
|
||||
protected async downloadThumbnail(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
savePath: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.info("Downloading thumbnail from:", thumbnailUrl);
|
||||
if (axiosConfig.proxy || axiosConfig.httpAgent) {
|
||||
logger.debug("Using proxy for thumbnail download");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
fs.ensureDirSync(path.dirname(savePath));
|
||||
@@ -58,6 +65,8 @@ export abstract class BaseDownloader implements IDownloader {
|
||||
method: "GET",
|
||||
url: thumbnailUrl,
|
||||
responseType: "stream",
|
||||
timeout: BaseDownloader.THUMBNAIL_DOWNLOAD_TIMEOUT,
|
||||
...axiosConfig,
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(savePath);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SUBTITLES_DIR } from "../../config/paths";
|
||||
import { extractBilibiliVideoId } from "../../utils/helpers";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
@@ -22,7 +23,7 @@ export type {
|
||||
BilibiliVideoInfo,
|
||||
BilibiliVideosResult,
|
||||
CollectionDownloadResult,
|
||||
DownloadResult,
|
||||
DownloadResult
|
||||
};
|
||||
|
||||
export class BilibiliDownloader extends BaseDownloader {
|
||||
@@ -183,6 +184,11 @@ export class BilibiliDownloader extends BaseDownloader {
|
||||
videoUrl: string,
|
||||
baseFilename: string
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
return bilibiliSubtitle.downloadSubtitles(videoUrl, baseFilename);
|
||||
return bilibiliSubtitle.downloadSubtitles(
|
||||
videoUrl,
|
||||
baseFilename,
|
||||
SUBTITLES_DIR,
|
||||
"/subtitles"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ import { cleanupTemporaryFiles, safeRemove } from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { ProgressTracker } from "../../utils/progressTracker";
|
||||
import {
|
||||
flagsToArgs,
|
||||
getAxiosProxyConfig,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
InvalidProxyError,
|
||||
} from "../../utils/ytDlpUtils";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
@@ -150,41 +157,26 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
thumbnail: thumbnailUrl,
|
||||
});
|
||||
|
||||
// 3. Select the best m3u8 URL from collected URLs
|
||||
// Prefer specific quality playlists over master playlists
|
||||
let m3u8Url: string | null = null;
|
||||
if (m3u8Urls.length > 0) {
|
||||
// Sort URLs: prefer specific quality playlists, avoid master playlists
|
||||
const sortedUrls = m3u8Urls.sort((a, b) => {
|
||||
const aIsMaster =
|
||||
a.includes("/playlist.m3u8") || a.includes("/master/");
|
||||
const bIsMaster =
|
||||
b.includes("/playlist.m3u8") || b.includes("/master/");
|
||||
// 3. Get user's yt-dlp configuration early to check for format sort
|
||||
// This helps determine m3u8 URL selection strategy and will be reused later
|
||||
const userConfig = getUserYtDlpConfig(url);
|
||||
const hasFormatSort = !!(userConfig.S || userConfig.formatSort);
|
||||
|
||||
// Prefer non-master playlists
|
||||
if (aIsMaster && !bIsMaster) return 1;
|
||||
if (!aIsMaster && bIsMaster) return -1;
|
||||
// 4. Select the best m3u8 URL from collected URLs
|
||||
let m3u8Url = MissAVDownloader.selectBestM3u8Url(m3u8Urls, hasFormatSort);
|
||||
|
||||
// Among non-master playlists, prefer higher quality (480p > 240p)
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
});
|
||||
|
||||
m3u8Url = sortedUrls[0];
|
||||
if (m3u8Url) {
|
||||
logger.info(
|
||||
`Selected m3u8 URL from ${m3u8Urls.length} candidates:`,
|
||||
`Selected m3u8 URL from ${m3u8Urls.length} candidates (format sort: ${hasFormatSort}):`,
|
||||
m3u8Url
|
||||
);
|
||||
if (sortedUrls.length > 1) {
|
||||
logger.info("Alternative URLs:", sortedUrls.slice(1));
|
||||
const alternatives = m3u8Urls.filter((u) => u !== m3u8Url);
|
||||
if (alternatives.length > 0) {
|
||||
logger.info("Alternative URLs:", alternatives);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
// 5. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
if (!m3u8Url) {
|
||||
logger.info(
|
||||
"m3u8 URL not found via network, trying regex extraction..."
|
||||
@@ -229,19 +221,32 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update the safe base filename with the actual title
|
||||
// 5. Get network configuration from user config (already loaded above)
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Get merge output format from user config or default to mp4
|
||||
const mergeOutputFormat = userConfig.mergeOutputFormat || "mp4";
|
||||
|
||||
// 6. Update the safe base filename with the actual title
|
||||
// Use the correct extension based on merge output format
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newVideoFilename = `${newSafeBaseFilename}.${mergeOutputFormat}`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const thumbnailDir = moveThumbnailsToVideoFolder
|
||||
? VIDEOS_DIR
|
||||
: IMAGES_DIR;
|
||||
const newThumbnailPath = path.join(thumbnailDir, newThumbnailFilename);
|
||||
|
||||
// 6. Download the video using yt-dlp with the m3u8 URL
|
||||
// 7. Download the video using yt-dlp with the m3u8 URL
|
||||
logger.info("Downloading video from m3u8 URL using yt-dlp:", m3u8Url);
|
||||
logger.info("Downloading video to:", newVideoPath);
|
||||
logger.info("Download ID:", downloadId);
|
||||
@@ -257,19 +262,53 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// Get format sort option if user specified it
|
||||
const formatSortValue = userConfig.S || userConfig.formatSort;
|
||||
|
||||
// Default format - use bestvideo*+bestaudio/best to support highest resolution
|
||||
// This allows downloading 1080p or higher if available
|
||||
let downloadFormat = "bestvideo*+bestaudio/best";
|
||||
|
||||
// If user specified a format, use it
|
||||
if (userConfig.f || userConfig.format) {
|
||||
downloadFormat = userConfig.f || userConfig.format;
|
||||
logger.info("Using user-specified format for MissAV:", downloadFormat);
|
||||
} else if (formatSortValue) {
|
||||
// If user specified format sort but not format, use a more permissive format
|
||||
// that allows format sort to work properly with m3u8 streams
|
||||
// This ensures format sort (e.g., -S res:360) can properly filter resolutions
|
||||
downloadFormat = "bestvideo+bestaudio/best";
|
||||
logger.info(
|
||||
"Using permissive format with format sort for MissAV:",
|
||||
downloadFormat,
|
||||
"format sort:",
|
||||
formatSortValue
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare flags for yt-dlp to download m3u8 stream
|
||||
// Dynamically determine Referer based on the input URL domain
|
||||
const urlObj = new URL(url);
|
||||
const referer = `${urlObj.protocol}//${urlObj.host}/`;
|
||||
const urlObjForReferer = new URL(url);
|
||||
const referer = `${urlObjForReferer.protocol}//${urlObjForReferer.host}/`;
|
||||
logger.info("Using Referer:", referer);
|
||||
|
||||
// Prepare flags object - merge user config with required settings
|
||||
const flags: any = {
|
||||
...networkConfig, // Apply network settings (proxy, etc.)
|
||||
output: newVideoPath,
|
||||
format: "best",
|
||||
mergeOutputFormat: "mp4",
|
||||
format: downloadFormat,
|
||||
mergeOutputFormat: mergeOutputFormat,
|
||||
addHeader: [`Referer:${referer}`, `User-Agent:${userAgent}`],
|
||||
};
|
||||
|
||||
// Apply format sort if user specified it
|
||||
if (formatSortValue) {
|
||||
flags.formatSort = formatSortValue;
|
||||
logger.info("Using format sort for MissAV:", formatSortValue);
|
||||
}
|
||||
|
||||
logger.info("Final MissAV yt-dlp flags:", flags);
|
||||
|
||||
// Use ProgressTracker for centralized progress parsing
|
||||
const progressTracker = new ProgressTracker(downloadId);
|
||||
const parseProgress = (output: string, source: "stdout" | "stderr") => {
|
||||
@@ -286,20 +325,11 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
|
||||
logger.info("Starting yt-dlp process with spawn...");
|
||||
|
||||
// Convert flags object to array of args
|
||||
const args = [
|
||||
m3u8Url,
|
||||
"--output",
|
||||
newVideoPath,
|
||||
"--format",
|
||||
"best",
|
||||
"--merge-output-format",
|
||||
"mp4",
|
||||
"--add-header",
|
||||
`Referer:${referer}`,
|
||||
"--add-header",
|
||||
`User-Agent:${userAgent}`,
|
||||
];
|
||||
// Convert flags object to array of args using the utility function
|
||||
const args = [m3u8Url, ...flagsToArgs(flags)];
|
||||
|
||||
// Log the full command for debugging
|
||||
logger.info("Executing yt-dlp command:", YT_DLP_PATH, args.join(" "));
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -357,17 +387,33 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 7. Download and save the thumbnail
|
||||
// 8. Download and save the thumbnail
|
||||
if (thumbnailUrl) {
|
||||
// Use base class method via temporary instance
|
||||
let axiosConfig = {};
|
||||
if (userConfig.proxy) {
|
||||
try {
|
||||
axiosConfig = getAxiosProxyConfig(userConfig.proxy);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProxyError) {
|
||||
logger.warn(
|
||||
"Invalid proxy configuration for thumbnail download, proceeding without proxy:",
|
||||
error.message
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const downloader = new MissAVDownloader();
|
||||
thumbnailSaved = await downloader.downloadThumbnail(
|
||||
thumbnailUrl,
|
||||
newThumbnailPath
|
||||
newThumbnailPath,
|
||||
axiosConfig
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Get video duration
|
||||
// 9. Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import(
|
||||
@@ -381,7 +427,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
logger.error("Failed to extract duration from MissAV video:", e);
|
||||
}
|
||||
|
||||
// 9. Get file size
|
||||
// 10. Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
@@ -392,7 +438,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
logger.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// 10. Save metadata
|
||||
// 11. Save metadata
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle,
|
||||
@@ -405,7 +451,9 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${newVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${newThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${newThumbnailFilename}`
|
||||
: `/images/${newThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
@@ -419,20 +467,134 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
return videoData;
|
||||
} catch (error: any) {
|
||||
logger.error("Error in downloadMissAVVideo:", error);
|
||||
// Cleanup
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoPath = path.join(VIDEOS_DIR, `${newSafeBaseFilename}.mp4`);
|
||||
const newThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${newSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(newVideoPath)) await safeRemove(newVideoPath);
|
||||
if (fs.existsSync(newThumbnailPath)) await safeRemove(newThumbnailPath);
|
||||
// Cleanup - try to get the correct extension from config, fallback to mp4
|
||||
try {
|
||||
const cleanupConfig = getUserYtDlpConfig(url);
|
||||
const cleanupFormat = cleanupConfig.mergeOutputFormat || "mp4";
|
||||
const cleanupSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const cleanupVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.${cleanupFormat}`
|
||||
);
|
||||
const cleanupThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${cleanupSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPath)) await safeRemove(cleanupVideoPath);
|
||||
if (fs.existsSync(cleanupThumbnailPath))
|
||||
await safeRemove(cleanupThumbnailPath);
|
||||
// Also try mp4 in case the file was created with default extension
|
||||
const cleanupVideoPathMp4 = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.mp4`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPathMp4))
|
||||
await safeRemove(cleanupVideoPathMp4);
|
||||
} catch (cleanupError) {
|
||||
// If cleanup fails, try with default mp4 extension
|
||||
const cleanupSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const cleanupVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.mp4`
|
||||
);
|
||||
const cleanupThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${cleanupSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPath)) await safeRemove(cleanupVideoPath);
|
||||
if (fs.existsSync(cleanupThumbnailPath))
|
||||
await safeRemove(cleanupThumbnailPath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to select best m3u8 URL
|
||||
static selectBestM3u8Url(
|
||||
urls: string[],
|
||||
hasFormatSort: boolean
|
||||
): string | null {
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
const sortedUrls = [...urls].sort((a, b) => {
|
||||
// 1. Priority: surrit.com
|
||||
const aIsSurrit = a.includes("surrit.com");
|
||||
const bIsSurrit = b.includes("surrit.com");
|
||||
if (aIsSurrit && !bIsSurrit) return -1;
|
||||
if (!aIsSurrit && bIsSurrit) return 1;
|
||||
|
||||
// 2. Priority: Master playlist (playlist.m3u8 specifically for surrit, or general master)
|
||||
// We generally prefer master playlists because they contain all variants, allowing yt-dlp to pick the best.
|
||||
// The previous logic penalized master playlists without explicit resolution, which caused issues.
|
||||
const aIsMaster = a.includes("/playlist.m3u8") || a.includes("/master/");
|
||||
const bIsMaster = b.includes("/playlist.m3u8") || b.includes("/master/");
|
||||
|
||||
// If we are strictly comparing surrit URLs (both are surrit), we prefer the master playlist
|
||||
// because it's the "cleanest" source.
|
||||
if (aIsSurrit && bIsSurrit) {
|
||||
const aIsPlaylistM3u8 = a.includes("playlist.m3u8");
|
||||
const bIsPlaylistM3u8 = b.includes("playlist.m3u8");
|
||||
if (aIsPlaylistM3u8 && !bIsPlaylistM3u8) return -1;
|
||||
if (!aIsPlaylistM3u8 && bIsPlaylistM3u8) return 1;
|
||||
}
|
||||
|
||||
// If format sort is enabled, we almost always want the master playlist
|
||||
if (hasFormatSort) {
|
||||
if (aIsMaster && !bIsMaster) return -1;
|
||||
if (!aIsMaster && bIsMaster) return 1;
|
||||
} else {
|
||||
// If NO format sort, previously we preferred specific resolution.
|
||||
// BUT, given the bug report where a 240p stream was picked over a master,
|
||||
// we should probably trust the master playlist more particularly if the alternative is low quality.
|
||||
// However, if we have a high quality specific stream (e.g. 720p/1080p explicit), that might be fine.
|
||||
// Let's refine: If one is surrit master, pick it. (Handled by step 1 & surrit sub-logic)
|
||||
// If neither is surrit, and one is master...
|
||||
// If both are master or both are not master, compare resolution.
|
||||
}
|
||||
|
||||
// 3. Priority: Resolution (detected from URL)
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
|
||||
// If we have a significant resolution difference, we might prefer the higher one
|
||||
// UNLESS one is a master playlist and the other is a low res specific one.
|
||||
// If one is master (0p detected) and other is 240p, 0p (master) should win if it's likely to contain better streams.
|
||||
|
||||
// Updated Strategy:
|
||||
// If both have resolution, compare them.
|
||||
if (aQualityNum > 0 && bQualityNum > 0) {
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
}
|
||||
|
||||
// If one is master (assumed 0p from URL) and other is specific resolution:
|
||||
// If we are prioritizing master playlists (e.g. because of surrit or format sort), master wins.
|
||||
// If we are NOT specifically prioritizing master, we still might want to prefer it over very low res (e.g. < 480p).
|
||||
if (aIsMaster && bQualityNum > 0 && bQualityNum < 480) return -1; // Master wins over < 480p
|
||||
if (bIsMaster && aQualityNum > 0 && aQualityNum < 480) return 1; // Master wins over < 480p
|
||||
|
||||
// Fallback: Default to higher number (so 720p wins over 0p/master if we didn't catch it above)
|
||||
// This preserves 'best attempt' for specific high quality URLs if they exist not on surrit.
|
||||
if (aQualityNum !== bQualityNum) {
|
||||
return bQualityNum - aQualityNum;
|
||||
}
|
||||
|
||||
// Final tie-breaker: prefer master if all else equal
|
||||
if (aIsMaster && !bIsMaster) return -1;
|
||||
if (!aIsMaster && bIsMaster) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedUrls[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ export async function cleanupTempDir(tempDir: string): Promise<void> {
|
||||
*/
|
||||
export function prepareFilePaths(
|
||||
mergeOutputFormat: string,
|
||||
collectionName?: string
|
||||
collectionName?: string,
|
||||
moveThumbnailsToVideoFolder: boolean = false
|
||||
): FilePaths {
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -61,9 +62,13 @@ export function prepareFilePaths(
|
||||
const videoDir = collectionName
|
||||
? path.join(VIDEOS_DIR, collectionName)
|
||||
: VIDEOS_DIR;
|
||||
const imageDir = collectionName
|
||||
? path.join(IMAGES_DIR, collectionName)
|
||||
: IMAGES_DIR;
|
||||
const imageDir = moveThumbnailsToVideoFolder
|
||||
? collectionName
|
||||
? path.join(VIDEOS_DIR, collectionName)
|
||||
: VIDEOS_DIR
|
||||
: collectionName
|
||||
? path.join(IMAGES_DIR, collectionName)
|
||||
: IMAGES_DIR;
|
||||
|
||||
// Ensure directories exist
|
||||
fs.ensureDirSync(videoDir);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from "axios";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { SUBTITLES_DIR } from "../../../config/paths";
|
||||
import { bccToVtt } from "../../../utils/bccToVtt";
|
||||
import { extractBilibiliVideoId } from "../../../utils/helpers";
|
||||
import { logger } from "../../../utils/logger";
|
||||
@@ -13,7 +12,9 @@ import { getCookieHeader } from "./bilibiliCookie";
|
||||
export async function downloadSubtitles(
|
||||
videoUrl: string,
|
||||
baseFilename: string,
|
||||
collectionName?: string
|
||||
subtitleDir: string,
|
||||
subtitlePathPrefix: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
try {
|
||||
const videoId = extractBilibiliVideoId(videoUrl);
|
||||
@@ -37,7 +38,7 @@ export async function downloadSubtitles(
|
||||
const viewApiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
let viewResponse;
|
||||
try {
|
||||
viewResponse = await axios.get(viewApiUrl, { headers });
|
||||
viewResponse = await axios.get(viewApiUrl, { headers, ...axiosConfig });
|
||||
} catch (viewError: any) {
|
||||
logger.error(`Failed to fetch view API: ${viewError.message}`);
|
||||
return [];
|
||||
@@ -55,7 +56,7 @@ export async function downloadSubtitles(
|
||||
logger.info(`Fetching subtitles from: ${playerApiUrl}`);
|
||||
let playerResponse;
|
||||
try {
|
||||
playerResponse = await axios.get(playerApiUrl, { headers });
|
||||
playerResponse = await axios.get(playerApiUrl, { headers, ...axiosConfig });
|
||||
} catch (playerError: any) {
|
||||
logger.warn(`Player API failed: ${playerError.message}`);
|
||||
// Continue to check view API fallback
|
||||
@@ -95,14 +96,6 @@ export async function downloadSubtitles(
|
||||
|
||||
const savedSubtitles = [];
|
||||
|
||||
// Determine subtitle directory based on collection name
|
||||
const subtitleDir = collectionName
|
||||
? path.join(SUBTITLES_DIR, collectionName)
|
||||
: SUBTITLES_DIR;
|
||||
const subtitlePathPrefix = collectionName
|
||||
? `/subtitles/${collectionName}`
|
||||
: `/subtitles`;
|
||||
|
||||
// Ensure subtitles directory exists
|
||||
fs.ensureDirSync(subtitleDir);
|
||||
|
||||
@@ -131,6 +124,7 @@ export async function downloadSubtitles(
|
||||
try {
|
||||
const subResponse = await axios.get(absoluteSubUrl, {
|
||||
headers: cdnHeaders,
|
||||
...axiosConfig,
|
||||
});
|
||||
const vttContent = bccToVtt(subResponse.data);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { SUBTITLES_DIR } from "../../../config/paths";
|
||||
import { DownloadCancelledError } from "../../../errors/DownloadErrors";
|
||||
import { formatBytes } from "../../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../../utils/helpers";
|
||||
@@ -8,8 +9,10 @@ import { ProgressTracker } from "../../../utils/progressTracker";
|
||||
import {
|
||||
executeYtDlpJson,
|
||||
executeYtDlpSpawn,
|
||||
getAxiosProxyConfig,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
InvalidProxyError,
|
||||
} from "../../../utils/ytDlpUtils";
|
||||
import * as storageService from "../../storageService";
|
||||
import { Video } from "../../storageService";
|
||||
@@ -55,9 +58,10 @@ class BilibiliDownloaderHelper extends BaseDownloader {
|
||||
|
||||
public async downloadThumbnailPublic(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
savePath: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<boolean> {
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath);
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath, axiosConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +259,26 @@ export async function downloadVideo(
|
||||
let thumbnailSaved = false;
|
||||
if (thumbnailUrl) {
|
||||
// Use base class method via temporary instance
|
||||
let axiosConfig = {};
|
||||
if (userConfig.proxy) {
|
||||
try {
|
||||
axiosConfig = getAxiosProxyConfig(userConfig.proxy);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProxyError) {
|
||||
logger.warn(
|
||||
"Invalid proxy configuration for thumbnail download, proceeding without proxy:",
|
||||
error.message
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const downloader = new BilibiliDownloaderHelper();
|
||||
thumbnailSaved = await downloader.downloadThumbnailPublic(
|
||||
thumbnailUrl,
|
||||
thumbnailPath
|
||||
thumbnailPath,
|
||||
axiosConfig
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +328,11 @@ export async function downloadSinglePart(
|
||||
// Get user's yt-dlp configuration for merge output format
|
||||
const userConfig = getUserYtDlpConfig(url);
|
||||
const mergeOutputFormat = userConfig.mergeOutputFormat || "mp4";
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const moveSubtitlesToVideoFolder =
|
||||
settings.moveSubtitlesToVideoFolder || false;
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -315,7 +340,8 @@ export async function downloadSinglePart(
|
||||
// Prepare file paths using the file manager
|
||||
const { videoPath, thumbnailPath, videoDir, imageDir } = prepareFilePaths(
|
||||
mergeOutputFormat,
|
||||
collectionName
|
||||
collectionName,
|
||||
moveThumbnailsToVideoFolder
|
||||
);
|
||||
|
||||
let videoTitle,
|
||||
@@ -420,10 +446,39 @@ export async function downloadSinglePart(
|
||||
}> = [];
|
||||
try {
|
||||
logger.info("Attempting to download subtitles...");
|
||||
const subtitleDir = moveSubtitlesToVideoFolder
|
||||
? videoDir
|
||||
: collectionName
|
||||
? path.join(SUBTITLES_DIR, collectionName)
|
||||
: SUBTITLES_DIR;
|
||||
const subtitlePathPrefix = moveSubtitlesToVideoFolder
|
||||
? collectionName
|
||||
? `/videos/${collectionName}`
|
||||
: `/videos`
|
||||
: collectionName
|
||||
? `/subtitles/${collectionName}`
|
||||
: `/subtitles`;
|
||||
let axiosConfig = {};
|
||||
if (userConfig.proxy) {
|
||||
try {
|
||||
axiosConfig = getAxiosProxyConfig(userConfig.proxy);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProxyError) {
|
||||
logger.warn(
|
||||
"Invalid proxy configuration for subtitle download, proceeding without proxy:",
|
||||
error.message
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
subtitles = await downloadSubtitles(
|
||||
url,
|
||||
newSafeBaseFilename,
|
||||
collectionName
|
||||
subtitleDir,
|
||||
subtitlePathPrefix,
|
||||
axiosConfig
|
||||
);
|
||||
logger.info(`Downloaded ${subtitles.length} subtitles`);
|
||||
} catch (e) {
|
||||
@@ -462,9 +517,13 @@ export async function downloadSinglePart(
|
||||
? `/videos/${collectionName}/${finalVideoFilename}`
|
||||
: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? collectionName
|
||||
? `/images/${collectionName}/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? collectionName
|
||||
? `/videos/${collectionName}/${finalThumbnailFilename}`
|
||||
: `/videos/${finalThumbnailFilename}`
|
||||
: collectionName
|
||||
? `/images/${collectionName}/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
|
||||
@@ -37,6 +37,7 @@ export function prepareDownloadFlags(
|
||||
}
|
||||
|
||||
// Prepare base flags from user config (excluding output options we manage)
|
||||
// Explicitly preserve network-related options like proxy
|
||||
const {
|
||||
output: _output, // Ignore user output template (we manage this)
|
||||
o: _o,
|
||||
@@ -50,9 +51,18 @@ export function prepareDownloadFlags(
|
||||
convertSubs: userConvertSubs,
|
||||
// Extract user merge output format (use it if provided)
|
||||
mergeOutputFormat: userMergeOutputFormat,
|
||||
proxy: _proxy, // Proxy is handled separately in networkOptions to ensure it's preserved
|
||||
...safeUserConfig
|
||||
} = config;
|
||||
|
||||
// Explicitly preserve proxy and other network options to ensure they're not lost
|
||||
// This is critical for download operations that need proxy settings
|
||||
const networkOptions: Record<string, any> = {};
|
||||
if (config.proxy) {
|
||||
networkOptions.proxy = config.proxy;
|
||||
logger.debug("Preserving proxy in networkOptions:", config.proxy);
|
||||
}
|
||||
|
||||
// Get format sort option if user specified it
|
||||
const formatSortValue = userFormatSort || userFormatSort2;
|
||||
|
||||
@@ -73,8 +83,10 @@ export function prepareDownloadFlags(
|
||||
const mergeOutputFormat = userMergeOutputFormat || defaultMergeFormat;
|
||||
|
||||
// Prepare flags - defaults first, then user config to allow overrides
|
||||
// Network options (like proxy) are applied last to ensure they're not overridden
|
||||
const flags: YtDlpFlags = {
|
||||
...safeUserConfig, // Apply user config
|
||||
...networkOptions, // Explicitly apply network options (proxy, etc.) to ensure they're preserved
|
||||
output: outputPath, // Always use our output path with correct extension
|
||||
format: defaultFormat,
|
||||
// Use user preferences if provided, otherwise use defaults
|
||||
@@ -99,7 +111,9 @@ export function prepareDownloadFlags(
|
||||
"bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best";
|
||||
}
|
||||
// Ensure merge output format is mp4 (already handled above, but log it)
|
||||
logger.info("Twitter/X URL detected - using MP4 format for Safari compatibility");
|
||||
logger.info(
|
||||
"Twitter/X URL detected - using MP4 format for Safari compatibility"
|
||||
);
|
||||
}
|
||||
|
||||
// Add YouTube specific flags if it's a YouTube URL
|
||||
@@ -153,6 +167,16 @@ export function prepareDownloadFlags(
|
||||
delete flags.extractorArgs;
|
||||
}
|
||||
|
||||
// Log proxy in final flags for debugging
|
||||
if (flags.proxy) {
|
||||
logger.debug("Proxy in final flags:", flags.proxy);
|
||||
} else if (config.proxy) {
|
||||
logger.warn(
|
||||
"Proxy was in config but not in final flags. Config proxy:",
|
||||
config.proxy
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Final yt-dlp flags:", flags);
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,24 +32,51 @@ class YtDlpDownloaderHelper extends BaseDownloader {
|
||||
*/
|
||||
export async function processSubtitles(
|
||||
baseFilename: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
moveSubtitlesToVideoFolder: boolean = false
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
const subtitles: Array<{ language: string; filename: string; path: string }> =
|
||||
[];
|
||||
|
||||
logger.info(
|
||||
`Processing subtitles for ${baseFilename}, move to video folder: ${moveSubtitlesToVideoFolder}`
|
||||
);
|
||||
|
||||
const downloader = new YtDlpDownloaderHelper();
|
||||
|
||||
try {
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
const subtitleExtensions = new Set([
|
||||
".vtt",
|
||||
".srt",
|
||||
".ass",
|
||||
".ssa",
|
||||
".sub",
|
||||
".ttml",
|
||||
".dfxp",
|
||||
".sbv",
|
||||
]);
|
||||
const searchDirs = [VIDEOS_DIR, SUBTITLES_DIR];
|
||||
const subtitleFiles: Array<{ dir: string; file: string }> = [];
|
||||
const seenFiles = new Set<string>();
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
const files = fs.readdirSync(dir).filter((file: string) => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return file.startsWith(baseFilename) && subtitleExtensions.has(ext);
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
if (seenFiles.has(file)) {
|
||||
continue;
|
||||
}
|
||||
seenFiles.add(file);
|
||||
subtitleFiles.push({ dir, file });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${subtitleFiles.length} subtitle files`);
|
||||
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
for (const { dir, file: subtitleFile } of subtitleFiles) {
|
||||
// Check if download was cancelled during subtitle processing
|
||||
try {
|
||||
downloader.throwIfCancelledPublic(downloadId);
|
||||
@@ -60,27 +87,47 @@ export async function processSubtitles(
|
||||
|
||||
// Parse language from filename (e.g., video_123.en.vtt -> en)
|
||||
const match = subtitleFile.match(
|
||||
/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.vtt$/
|
||||
/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.[^.]+$/
|
||||
);
|
||||
const language = match ? match[1] : "unknown";
|
||||
const extension = path.extname(subtitleFile);
|
||||
|
||||
// Move subtitle to subtitles directory
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
const destSubFilename = `${baseFilename}.${language}.vtt`;
|
||||
const destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
|
||||
// Move subtitle to subtitles directory or keep in video directory if requested
|
||||
const sourceSubPath = path.join(dir, subtitleFile);
|
||||
const destSubFilename = `${baseFilename}.${language}${extension}`;
|
||||
let destSubPath: string;
|
||||
let webPath: string;
|
||||
|
||||
// Read VTT file and fix alignment for centering
|
||||
let vttContent = fs.readFileSync(sourceSubPath, "utf-8");
|
||||
// Replace align:start with align:middle for centered subtitles
|
||||
// Also remove position:0% which forces left positioning
|
||||
vttContent = vttContent.replace(/ align:start/g, " align:middle");
|
||||
vttContent = vttContent.replace(/ position:0%/g, "");
|
||||
if (moveSubtitlesToVideoFolder) {
|
||||
destSubPath = path.join(VIDEOS_DIR, destSubFilename);
|
||||
webPath = `/videos/${destSubFilename}`;
|
||||
} else {
|
||||
destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
|
||||
webPath = `/subtitles/${destSubFilename}`;
|
||||
}
|
||||
|
||||
// Write cleaned VTT to destination
|
||||
fs.writeFileSync(destSubPath, vttContent, "utf-8");
|
||||
if (extension.toLowerCase() === ".vtt") {
|
||||
// Read VTT file and fix alignment for centering
|
||||
let vttContent = fs.readFileSync(sourceSubPath, "utf-8");
|
||||
// Replace align:start with align:middle for centered subtitles
|
||||
// Also remove position:0% which forces left positioning
|
||||
vttContent = vttContent.replace(/ align:start/g, " align:middle");
|
||||
vttContent = vttContent.replace(/ position:0%/g, "");
|
||||
|
||||
// Remove original file
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
// Write cleaned VTT to destination
|
||||
fs.writeFileSync(destSubPath, vttContent, "utf-8");
|
||||
} else if (sourceSubPath !== destSubPath) {
|
||||
fs.copyFileSync(sourceSubPath, destSubPath);
|
||||
}
|
||||
|
||||
// Remove original file if we moved it (if dest is different from source)
|
||||
// If moveSubtitlesToVideoFolder is true, destSubPath might be same as sourceSubPath
|
||||
// but with different name (e.g. video_uuid.en.vtt vs video_uuid.vtt)
|
||||
// Actually source is usually video_uuid.en.vtt (from yt-dlp) and dest is video_uuid.en.vtt
|
||||
// So if names are same and dir is same, we're just overwriting in place, which is fine
|
||||
if (sourceSubPath !== destSubPath) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Processed and moved subtitle ${subtitleFile} to ${destSubPath}`
|
||||
@@ -89,7 +136,7 @@ export async function processSubtitles(
|
||||
subtitles.push({
|
||||
language,
|
||||
filename: destSubFilename,
|
||||
path: `/subtitles/${destSubFilename}`,
|
||||
path: webPath,
|
||||
});
|
||||
}
|
||||
} catch (subtitleError) {
|
||||
@@ -100,4 +147,3 @@ export async function processSubtitles(
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import { ProgressTracker } from "../../../utils/progressTracker";
|
||||
import {
|
||||
executeYtDlpJson,
|
||||
executeYtDlpSpawn,
|
||||
getAxiosProxyConfig,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
InvalidProxyError,
|
||||
} from "../../../utils/ytDlpUtils";
|
||||
import * as storageService from "../../storageService";
|
||||
import { Video } from "../../storageService";
|
||||
@@ -44,9 +47,10 @@ class YtDlpDownloaderHelper extends BaseDownloader {
|
||||
|
||||
public async downloadThumbnailPublic(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
savePath: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<boolean> {
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath);
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath, axiosConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +90,13 @@ export async function downloadVideo(
|
||||
try {
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Get user's yt-dlp configuration for network options (including proxy)
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Get video info first
|
||||
const info = await executeYtDlpJson(videoUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
preferFreeFormats: true,
|
||||
...(PROVIDER_SCRIPT
|
||||
@@ -130,8 +139,23 @@ export async function downloadVideo(
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
|
||||
// Update paths
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const moveSubtitlesToVideoFolder =
|
||||
settings.moveSubtitlesToVideoFolder || false;
|
||||
|
||||
logger.info("File location settings:", {
|
||||
moveThumbnailsToVideoFolder,
|
||||
moveSubtitlesToVideoFolder,
|
||||
videoDir: VIDEOS_DIR,
|
||||
imageDir: IMAGES_DIR
|
||||
});
|
||||
|
||||
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
const newThumbnailPath = moveThumbnailsToVideoFolder
|
||||
? path.join(VIDEOS_DIR, finalThumbnailFilename)
|
||||
: path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
|
||||
logger.info("Preparing video download path:", newVideoPath);
|
||||
|
||||
@@ -142,16 +166,32 @@ export async function downloadVideo(
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's yt-dlp configuration
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
// Get user's yt-dlp configuration (reuse from above if available, otherwise fetch again)
|
||||
// Note: userConfig was already fetched above, but we need to ensure it's still valid
|
||||
const downloadUserConfig = userConfig || getUserYtDlpConfig(videoUrl);
|
||||
|
||||
// Log proxy configuration for debugging
|
||||
if (downloadUserConfig.proxy) {
|
||||
logger.info("Using proxy for download:", downloadUserConfig.proxy);
|
||||
}
|
||||
|
||||
// Prepare download flags
|
||||
const { flags, mergeOutputFormat } = prepareDownloadFlags(
|
||||
videoUrl,
|
||||
newVideoPath,
|
||||
userConfig
|
||||
downloadUserConfig
|
||||
);
|
||||
|
||||
// Log final flags to verify proxy is included
|
||||
if (flags.proxy) {
|
||||
logger.info("Proxy included in download flags:", flags.proxy);
|
||||
} else {
|
||||
logger.warn(
|
||||
"Proxy not found in download flags. User config proxy:",
|
||||
downloadUserConfig.proxy
|
||||
);
|
||||
}
|
||||
|
||||
// Update the video path to use the correct extension based on merge format
|
||||
const videoExtension = mergeOutputFormat;
|
||||
const newVideoPathWithFormat = newVideoPath.replace(
|
||||
@@ -181,7 +221,13 @@ export async function downloadVideo(
|
||||
// Clean up partial files
|
||||
logger.info("Cleaning up partial files...");
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename);
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename, IMAGES_DIR);
|
||||
|
||||
// Use fresh cleanup based on settings
|
||||
const currentSettings = storageService.getSettings();
|
||||
if (!currentSettings.moveThumbnailsToVideoFolder) {
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename, IMAGES_DIR);
|
||||
}
|
||||
|
||||
if (fs.existsSync(newThumbnailPath)) {
|
||||
await fs.remove(newThumbnailPath);
|
||||
}
|
||||
@@ -256,9 +302,30 @@ export async function downloadVideo(
|
||||
thumbnailSaved = false;
|
||||
|
||||
if (thumbnailUrl) {
|
||||
// Prepare axios config with proxy if available
|
||||
let axiosConfig = {};
|
||||
|
||||
if (downloadUserConfig.proxy) {
|
||||
try {
|
||||
axiosConfig = getAxiosProxyConfig(downloadUserConfig.proxy);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidProxyError) {
|
||||
// Log the error but continue without proxy for thumbnail
|
||||
// Video download already succeeded, don't fail for thumbnail proxy issues
|
||||
logger.warn(
|
||||
"Invalid proxy configuration for thumbnail download, proceeding without proxy:",
|
||||
error.message
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thumbnailSaved = await downloader.downloadThumbnailPublic(
|
||||
thumbnailUrl,
|
||||
newThumbnailPath
|
||||
newThumbnailPath,
|
||||
axiosConfig
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,13 +338,21 @@ export async function downloadVideo(
|
||||
}
|
||||
|
||||
// Process subtitle files
|
||||
subtitles = await processSubtitles(newSafeBaseFilename, downloadId);
|
||||
subtitles = await processSubtitles(
|
||||
newSafeBaseFilename,
|
||||
downloadId,
|
||||
moveSubtitlesToVideoFolder
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error in download process:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
@@ -290,7 +365,11 @@ export async function downloadVideo(
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
subtitles: subtitles.length > 0 ? subtitles : undefined,
|
||||
duration: undefined, // Will be populated below
|
||||
channelUrl: channelUrl || undefined,
|
||||
@@ -345,7 +424,9 @@ export async function downloadVideo(
|
||||
? finalThumbnailFilename
|
||||
: existingVideo.thumbnailFilename,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: existingVideo.thumbnailPath,
|
||||
duration: videoData.duration,
|
||||
fileSize: videoData.fileSize,
|
||||
|
||||
343
backend/src/services/passkeyService.ts
Normal file
343
backend/src/services/passkeyService.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type {
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { generateToken } from "./authService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
// RP (Relying Party) configuration
|
||||
const rpName = "MyTube";
|
||||
const rpID = process.env.RP_ID || "localhost"; // Default to localhost for development
|
||||
export const defaultOrigin = process.env.ORIGIN || `http://${rpID}:5550`; // Frontend origin
|
||||
const origin = defaultOrigin;
|
||||
|
||||
// Storage key for passkeys
|
||||
const PASSKEYS_STORAGE_KEY = "passkeys";
|
||||
|
||||
interface StoredPasskey {
|
||||
credentialID: string; // Base64url encoded
|
||||
credentialPublicKey: string; // Base64 encoded
|
||||
counter: number;
|
||||
transports?: string[];
|
||||
id: string; // Same as credentialID for convenience
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
rpID?: string; // Store the RP_ID used during registration for debugging
|
||||
origin?: string; // Store the origin used during registration for debugging
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored passkeys
|
||||
*/
|
||||
export function getPasskeys(): StoredPasskey[] {
|
||||
try {
|
||||
const settings = storageService.getSettings();
|
||||
const passkeys = settings[PASSKEYS_STORAGE_KEY];
|
||||
if (!passkeys || !Array.isArray(passkeys)) {
|
||||
return [];
|
||||
}
|
||||
return passkeys;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error getting passkeys",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save passkeys to storage
|
||||
*/
|
||||
function savePasskeys(passkeys: StoredPasskey[]): void {
|
||||
try {
|
||||
storageService.saveSettings({
|
||||
[PASSKEYS_STORAGE_KEY]: passkeys,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error saving passkeys",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate registration options for creating a new passkey
|
||||
*/
|
||||
export async function generatePasskeyRegistrationOptions(
|
||||
userName: string = "MyTube User",
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{
|
||||
options: any;
|
||||
challenge: string;
|
||||
}> {
|
||||
const existingPasskeys = getPasskeys();
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName,
|
||||
rpID: effectiveRPID,
|
||||
userID: Buffer.from(userName),
|
||||
userName,
|
||||
timeout: 60000,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existingPasskeys.map((passkey) => ({
|
||||
id: Buffer.from(passkey.credentialID, "base64url").toString("base64url"),
|
||||
type: "public-key" as const,
|
||||
transports: passkey.transports as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: "platform",
|
||||
userVerification: "preferred",
|
||||
requireResidentKey: false,
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
|
||||
};
|
||||
|
||||
const options = await generateRegistrationOptions(opts);
|
||||
|
||||
// Store challenge temporarily (in a real app, you'd use a session or cache)
|
||||
// For simplicity, we'll return it and the frontend will send it back
|
||||
return {
|
||||
options,
|
||||
challenge: options.challenge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and store a new passkey
|
||||
*/
|
||||
export async function verifyPasskeyRegistration(
|
||||
body: any,
|
||||
challenge: string,
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{ verified: boolean; passkey?: StoredPasskey }> {
|
||||
try {
|
||||
const existingPasskeys = getPasskeys();
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
const effectiveOrigin = originOverride || origin;
|
||||
|
||||
logger.info(
|
||||
`Verifying passkey registration with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
|
||||
);
|
||||
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
response: body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: effectiveOrigin,
|
||||
expectedRPID: effectiveRPID,
|
||||
requireUserVerification: false,
|
||||
};
|
||||
|
||||
const verification = await verifyRegistrationResponse(opts);
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { credential } = verification.registrationInfo;
|
||||
const credentialID = credential.id;
|
||||
const credentialPublicKey = Buffer.from(credential.publicKey).toString(
|
||||
"base64"
|
||||
);
|
||||
|
||||
const newPasskey: StoredPasskey = {
|
||||
credentialID,
|
||||
credentialPublicKey,
|
||||
counter: credential.counter || 0,
|
||||
transports: body.response.transports || credential.transports || [],
|
||||
id: credentialID,
|
||||
name: body.name || `Passkey ${existingPasskeys.length + 1}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
rpID: effectiveRPID, // Store RP_ID for debugging
|
||||
origin: effectiveOrigin, // Store origin for debugging
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`Passkey registered successfully with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
|
||||
);
|
||||
|
||||
const updatedPasskeys = [...existingPasskeys, newPasskey];
|
||||
savePasskeys(updatedPasskeys);
|
||||
|
||||
logger.info("New passkey registered successfully");
|
||||
return { verified: true, passkey: newPasskey };
|
||||
}
|
||||
|
||||
return { verified: false };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error verifying passkey registration",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
return { verified: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authentication options for passkey login
|
||||
*/
|
||||
export async function generatePasskeyAuthenticationOptions(
|
||||
rpIDOverride?: string
|
||||
): Promise<{
|
||||
options: any;
|
||||
challenge: string;
|
||||
}> {
|
||||
const passkeys = getPasskeys();
|
||||
|
||||
if (passkeys.length === 0) {
|
||||
throw new Error("No passkeys registered");
|
||||
}
|
||||
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
|
||||
logger.info(
|
||||
`Generating authentication options with RP_ID: ${effectiveRPID}, Found ${passkeys.length} passkey(s)`
|
||||
);
|
||||
|
||||
// Log stored RP_IDs for debugging
|
||||
const storedRPIDs = passkeys.map((p) => p.rpID || "not set");
|
||||
logger.info(`Stored passkeys RP_IDs: ${storedRPIDs.join(", ")}`);
|
||||
|
||||
// Filter passkeys to only include those that match the current RP_ID
|
||||
// This is critical - browsers will only find passkeys that match the RP_ID
|
||||
const matchingPasskeys = passkeys.filter((passkey) => {
|
||||
// If passkey has stored RP_ID, it must match
|
||||
if (passkey.rpID) {
|
||||
return passkey.rpID === effectiveRPID;
|
||||
}
|
||||
// For passkeys without stored RP_ID (legacy data), include them as fallback
|
||||
// This allows old passkeys to still work
|
||||
return true;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Using ${matchingPasskeys.length} passkey(s) matching RP_ID: ${effectiveRPID}`
|
||||
);
|
||||
|
||||
if (matchingPasskeys.length === 0) {
|
||||
throw new Error(
|
||||
`No passkeys found for RP_ID: ${effectiveRPID}. Please create a new passkey.`
|
||||
);
|
||||
}
|
||||
|
||||
// Since we only allow platform authenticators during registration (authenticatorAttachment: "platform"),
|
||||
// all passkeys should be platform authenticators. Explicitly set transports to ["internal"]
|
||||
// to ensure the browser uses the platform authenticator (fingerprint/face ID) instead of
|
||||
// falling back to cross-platform authentication (QR code)
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
timeout: 60000,
|
||||
allowCredentials: matchingPasskeys.map((passkey) => ({
|
||||
id: passkey.credentialID,
|
||||
type: "public-key" as const,
|
||||
// Always specify "internal" transport since we only register platform authenticators
|
||||
// This tells the browser to use the device's built-in authenticator (fingerprint/face ID)
|
||||
transports: ["internal"] as any,
|
||||
})),
|
||||
userVerification: "preferred",
|
||||
rpID: effectiveRPID,
|
||||
};
|
||||
|
||||
const options = await generateAuthenticationOptions(opts);
|
||||
|
||||
return {
|
||||
options,
|
||||
challenge: options.challenge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
*/
|
||||
export async function verifyPasskeyAuthentication(
|
||||
body: any,
|
||||
challenge: string,
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{ verified: boolean; token?: string; role?: "admin" | "visitor" }> {
|
||||
try {
|
||||
const passkeys = getPasskeys();
|
||||
// Find passkey by matching the credential ID
|
||||
// body.id is already in base64url format from the browser
|
||||
const passkey = passkeys.find((p) => p.credentialID === body.id);
|
||||
|
||||
if (!passkey) {
|
||||
logger.warn("Passkey not found for authentication");
|
||||
return { verified: false };
|
||||
}
|
||||
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
const effectiveOrigin = originOverride || origin;
|
||||
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
response: body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: effectiveOrigin,
|
||||
expectedRPID: effectiveRPID,
|
||||
credential: {
|
||||
id: passkey.credentialID,
|
||||
publicKey: Buffer.from(passkey.credentialPublicKey, "base64") as any,
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports as any,
|
||||
},
|
||||
requireUserVerification: false,
|
||||
};
|
||||
|
||||
const verification = await verifyAuthenticationResponse(opts);
|
||||
|
||||
if (verification.verified) {
|
||||
// Update counter
|
||||
const updatedPasskeys = passkeys.map((p) =>
|
||||
p.credentialID === passkey.credentialID
|
||||
? { ...p, counter: verification.authenticationInfo.newCounter }
|
||||
: p
|
||||
);
|
||||
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" };
|
||||
}
|
||||
|
||||
return { verified: false };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error verifying passkey authentication",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
return { verified: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all passkeys
|
||||
*/
|
||||
export function removeAllPasskeys(): void {
|
||||
try {
|
||||
savePasskeys([]);
|
||||
logger.info("All passkeys removed");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error removing passkeys",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { defaultSettings } from "../types/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as loginAttemptService from "./loginAttemptService";
|
||||
import * as storageService from "./storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Settings, defaultSettings } from "../types/settings";
|
||||
|
||||
/**
|
||||
* Check if login is required (loginEnabled is true)
|
||||
*/
|
||||
export function isLoginRequired(): boolean {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
return mergedSettings.loginEnabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password authentication is enabled
|
||||
@@ -11,12 +20,16 @@ import { Settings, defaultSettings } from "../types/settings";
|
||||
export function isPasswordEnabled(): {
|
||||
enabled: boolean;
|
||||
waitTime?: number;
|
||||
loginRequired?: boolean;
|
||||
} {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Return true only if login is enabled AND a password is set
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
// Return true only if login is enabled AND a password is set AND password login is allowed
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password && passwordLoginAllowed;
|
||||
|
||||
// Check for remaining wait time
|
||||
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||
@@ -24,16 +37,22 @@ export function isPasswordEnabled(): {
|
||||
return {
|
||||
enabled: isEnabled,
|
||||
waitTime: remainingWaitTime > 0 ? remainingWaitTime : undefined,
|
||||
loginRequired: mergedSettings.loginEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password for authentication
|
||||
* @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security
|
||||
*/
|
||||
import { generateToken } from "./authService";
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
role?: "admin" | "visitor";
|
||||
token?: string;
|
||||
waitTime?: number;
|
||||
failedAttempts?: number;
|
||||
message?: string;
|
||||
@@ -41,9 +60,19 @@ export async function verifyPassword(
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
if (!mergedSettings.password) {
|
||||
// If no password set but login enabled, allow access
|
||||
return { success: true };
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
// If password login is explicitly disabled, ONLY allow Admin to login via password (if they have one set)?
|
||||
// Or just block everyone? The frontend says "When disabled, password login is not available."
|
||||
// But typically Admin should always be able to login?
|
||||
// For now, let's respect the flag, but maybe we should allow it if we are matching the Admin password?
|
||||
// Let's stick to current logic: if blocked, blocked.
|
||||
if (!passwordLoginAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Password login is not allowed. Please use passkey authentication.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user can attempt login (wait time check)
|
||||
@@ -57,24 +86,187 @@ export async function verifyPassword(
|
||||
};
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
|
||||
if (isMatch) {
|
||||
// Reset failed attempts on successful login
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
return { success: true };
|
||||
// 1. Check Admin Password
|
||||
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 {
|
||||
// Record failed attempt and get wait time
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Visitor Password (if visitorPassword is set and visitor user is enabled)
|
||||
// Permission control is now based on user role
|
||||
// If password matches visitorPassword, assign visitor role
|
||||
const visitorUserEnabled = mergedSettings.visitorUserEnabled !== false;
|
||||
if (visitorUserEnabled && 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// No match
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect password",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect password",
|
||||
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 visitor user is enabled (defaults to true for backward compatibility)
|
||||
const visitorUserEnabled = mergedSettings.visitorUserEnabled !== false;
|
||||
|
||||
if (!visitorUserEnabled) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Visitor user is not enabled.",
|
||||
};
|
||||
}
|
||||
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +277,57 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
return await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
const RESET_PASSWORD_COOLDOWN = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
/**
|
||||
* Get the remaining cooldown time for password reset
|
||||
* Returns the remaining time in milliseconds, or 0 if no cooldown
|
||||
*/
|
||||
export function getResetPasswordCooldown(): number {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
const lastResetTime = (mergedSettings as any).lastPasswordResetTime as number | undefined;
|
||||
|
||||
if (!lastResetTime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const timeSinceLastReset = Date.now() - lastResetTime;
|
||||
const remainingCooldown = RESET_PASSWORD_COOLDOWN - timeSinceLastReset;
|
||||
|
||||
return remainingCooldown > 0 ? remainingCooldown : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password to a random 8-character string
|
||||
* Returns the new password (should be logged, not sent to frontend)
|
||||
*/
|
||||
export async function resetPassword(): Promise<string> {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Check if password reset is allowed (defaults to true for backward compatibility)
|
||||
const allowResetPassword = mergedSettings.allowResetPassword !== false;
|
||||
|
||||
if (!allowResetPassword) {
|
||||
throw new Error("Password reset is not allowed. The allowResetPassword setting is disabled.");
|
||||
}
|
||||
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
if (!passwordLoginAllowed) {
|
||||
throw new Error("Password reset is not allowed when password login is disabled");
|
||||
}
|
||||
|
||||
// Check cooldown period (1 hour)
|
||||
const remainingCooldown = getResetPasswordCooldown();
|
||||
if (remainingCooldown > 0) {
|
||||
const minutes = Math.ceil(remainingCooldown / (60 * 1000));
|
||||
throw new Error(`Password reset is on cooldown. Please wait ${minutes} minute${minutes !== 1 ? 's' : ''} before trying again.`);
|
||||
}
|
||||
|
||||
// Generate random 8-character password using cryptographically secure random
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
@@ -101,11 +339,10 @@ export async function resetPassword(): Promise<string> {
|
||||
// Hash the new password
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
// Update settings with new password
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
// Update settings with new password and reset timestamp
|
||||
mergedSettings.password = hashedPassword;
|
||||
mergedSettings.loginEnabled = true; // Ensure login is enabled
|
||||
(mergedSettings as any).lastPasswordResetTime = Date.now();
|
||||
|
||||
storageService.saveSettings(mergedSettings);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as storageService from "./storageService";
|
||||
import { Settings, defaultSettings } from "../types/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
/**
|
||||
* Validate and normalize settings values
|
||||
@@ -25,54 +25,6 @@ export function validateSettings(newSettings: Partial<Settings>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if visitor mode restrictions should apply
|
||||
*/
|
||||
export function checkVisitorModeRestrictions(
|
||||
existingSettings: Settings,
|
||||
newSettings: Partial<Settings>
|
||||
): {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tag deletions and update videos accordingly
|
||||
*/
|
||||
@@ -137,14 +89,31 @@ export async function prepareSettingsForSave(
|
||||
const prepared = { ...newSettings };
|
||||
|
||||
// Handle password hashing
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = existingSettings.passwordLoginAllowed !== false;
|
||||
|
||||
if (prepared.password) {
|
||||
// If password is provided, hash it
|
||||
prepared.password = await hashPassword(prepared.password);
|
||||
// If password login is not allowed, reject password updates
|
||||
if (!passwordLoginAllowed) {
|
||||
// Remove password from prepared settings to prevent update
|
||||
delete prepared.password;
|
||||
logger.warn("Password update rejected: password login is not allowed");
|
||||
} else {
|
||||
// If password is provided and allowed, hash it
|
||||
prepared.password = await hashPassword(prepared.password);
|
||||
}
|
||||
} else {
|
||||
// If password is empty/not provided, keep existing password
|
||||
prepared.password = existingSettings.password;
|
||||
}
|
||||
|
||||
// Handle visitor password hashing
|
||||
if (prepared.visitorPassword) {
|
||||
prepared.visitorPassword = await hashPassword(prepared.visitorPassword);
|
||||
} else {
|
||||
prepared.visitorPassword = existingSettings.visitorPassword;
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
const oldTags: string[] = existingSettings.tags || [];
|
||||
if (prepared.tags === undefined) {
|
||||
|
||||
@@ -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
|
||||
@@ -276,13 +288,16 @@ export function initializeStorage(): void {
|
||||
`
|
||||
)
|
||||
.run();
|
||||
if (result.changes > 0) {
|
||||
if (result && result.changes > 0) {
|
||||
logger.info(
|
||||
`Backfilled video_id for ${result.changes} download history items.`
|
||||
);
|
||||
}
|
||||
} 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(
|
||||
|
||||
@@ -17,7 +17,6 @@ describe('settings types', () => {
|
||||
websiteName: "MyTube",
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
visitorMode: false,
|
||||
infiniteScroll: false,
|
||||
videoColumns: 4,
|
||||
pauseOnFocusLoss: false,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface Settings {
|
||||
loginEnabled: boolean;
|
||||
password?: string;
|
||||
passwordLoginAllowed?: boolean;
|
||||
allowResetPassword?: boolean;
|
||||
defaultAutoPlay: boolean;
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
@@ -21,7 +23,8 @@ export interface Settings {
|
||||
proxyOnlyYoutube?: boolean;
|
||||
moveSubtitlesToVideoFolder?: boolean;
|
||||
moveThumbnailsToVideoFolder?: boolean;
|
||||
visitorMode?: boolean;
|
||||
visitorPassword?: string;
|
||||
visitorUserEnabled?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
cloudflaredTunnelEnabled?: boolean;
|
||||
@@ -47,10 +50,7 @@ export const defaultSettings: Settings = {
|
||||
websiteName: "MyTube",
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
visitorMode: false,
|
||||
infiniteScroll: false,
|
||||
videoColumns: 4,
|
||||
pauseOnFocusLoss: false,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -58,8 +58,12 @@ export function validatePathWithinDirectory(
|
||||
|
||||
// Reconstruct paths from validated components only
|
||||
// This ensures no path traversal sequences can exist
|
||||
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
|
||||
const sanitizedFilePath = path.isAbsolute(filePath)
|
||||
? path.sep + path.join(...sanitizedFilePathParts)
|
||||
: path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
|
||||
? path.sep + path.join(...sanitizedAllowedDirParts)
|
||||
: path.join(...sanitizedAllowedDirParts);
|
||||
|
||||
// Final validation: ensure reconstructed paths don't contain traversal sequences
|
||||
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
|
||||
@@ -130,8 +134,12 @@ export function resolveSafePath(filePath: string, allowedDir: string): string {
|
||||
|
||||
// Reconstruct paths from validated components only
|
||||
// This ensures no path traversal sequences can exist
|
||||
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
|
||||
const sanitizedFilePath = path.isAbsolute(filePath)
|
||||
? path.sep + path.join(...sanitizedFilePathParts)
|
||||
: path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
|
||||
? path.sep + path.join(...sanitizedAllowedDirParts)
|
||||
: path.join(...sanitizedAllowedDirParts);
|
||||
|
||||
// Final validation: ensure reconstructed paths don't contain traversal sequences
|
||||
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn } from "child_process";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||
import { DATA_DIR } from "../config/paths";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
@@ -509,3 +510,91 @@ export function getNetworkConfigFromUserConfig(
|
||||
|
||||
return networkOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when proxy configuration is invalid
|
||||
*/
|
||||
export class InvalidProxyError extends Error {
|
||||
readonly proxyUrl: string;
|
||||
readonly originalError?: Error;
|
||||
|
||||
constructor(proxyUrl: string, originalError?: Error) {
|
||||
super(`Invalid proxy URL: ${proxyUrl}`);
|
||||
this.name = "InvalidProxyError";
|
||||
this.proxyUrl = proxyUrl;
|
||||
this.originalError = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert a proxy URL string into an Axios config object
|
||||
* Supports http/https/socks5 proxies with authentication
|
||||
* Format: http://user:pass@host:port or socks5://user:pass@host:port
|
||||
*
|
||||
* @throws {InvalidProxyError} If the proxy URL is malformed - this prevents
|
||||
* silent fallback to direct connection which could expose user's real IP
|
||||
*/
|
||||
export function getAxiosProxyConfig(proxyUrl: string): any {
|
||||
if (!proxyUrl) return {};
|
||||
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
|
||||
// Validate that we have a hostname
|
||||
if (!url.hostname) {
|
||||
throw new InvalidProxyError(proxyUrl, new Error("Missing hostname"));
|
||||
}
|
||||
|
||||
// Check if this is a SOCKS proxy
|
||||
if (protocol.startsWith("socks")) {
|
||||
// Use SocksProxyAgent for SOCKS proxy support
|
||||
const agent = new SocksProxyAgent(proxyUrl);
|
||||
return {
|
||||
httpAgent: agent,
|
||||
httpsAgent: agent,
|
||||
proxy: false, // Disable axios built-in proxy when using custom agents
|
||||
};
|
||||
}
|
||||
|
||||
// Validate protocol for non-SOCKS proxies
|
||||
if (protocol !== "http" && protocol !== "https") {
|
||||
throw new InvalidProxyError(
|
||||
proxyUrl,
|
||||
new Error(`Unsupported proxy protocol: ${protocol}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Handle HTTP/HTTPS proxies
|
||||
const isHttps = protocol === "https";
|
||||
const defaultPort = isHttps ? 443 : 80;
|
||||
|
||||
// Axios proxy config structure
|
||||
const proxyConfig: any = {
|
||||
protocol: protocol,
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10) || defaultPort,
|
||||
};
|
||||
|
||||
if (url.username || url.password) {
|
||||
proxyConfig.auth = {
|
||||
username: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
};
|
||||
}
|
||||
|
||||
return { proxy: proxyConfig };
|
||||
} catch (error) {
|
||||
// Re-throw InvalidProxyError as-is
|
||||
if (error instanceof InvalidProxyError) {
|
||||
throw error;
|
||||
}
|
||||
// Wrap other errors (like URL parsing errors) in InvalidProxyError
|
||||
// This ensures we fail rather than silently falling back to direct connection
|
||||
console.error("Invalid proxy URL:", proxyUrl, error);
|
||||
throw new InvalidProxyError(
|
||||
proxyUrl,
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# API Endpoints
|
||||
|
||||
All API routes are mounted under `/api` unless noted otherwise.
|
||||
|
||||
## Video Download & Search
|
||||
|
||||
- `GET /api/search` - Search for videos online (YouTube)
|
||||
- `GET /api/search` - Search videos online (YouTube)
|
||||
- Query params: `query` (required), `limit` (optional, default: 8), `offset` (optional, default: 1)
|
||||
- `POST /api/download` - Download a video from supported platforms
|
||||
- Body: `{ url: string, ...options }`
|
||||
- Body: `{ youtubeUrl: string, ...options }`
|
||||
- Supports: YouTube, Bilibili, MissAV, and all yt-dlp supported sites
|
||||
- `GET /api/check-video-download` - Check if a video has already been downloaded
|
||||
- Query params: `url` (required)
|
||||
@@ -16,8 +18,8 @@
|
||||
- Query params: `url` (required)
|
||||
- `GET /api/check-playlist` - Check if a URL is a supported playlist
|
||||
- Query params: `url` (required)
|
||||
- `GET /api/download-status` - Get status of active downloads
|
||||
- Returns: `{ active: [], queued: [] }`
|
||||
- `GET /api/download-status` - Get active/queued download status
|
||||
- Returns: `{ activeDownloads: DownloadInfo[], queuedDownloads: DownloadInfo[] }`
|
||||
|
||||
## Video Management
|
||||
|
||||
@@ -40,7 +42,6 @@
|
||||
- `GET /api/videos/author-channel-url` - Get author channel URL for a video
|
||||
- Query params: `sourceUrl` (required)
|
||||
- Returns: `{ success: boolean, channelUrl: string | null }`
|
||||
- Checks database first, then fetches from YouTube/Bilibili API if not found
|
||||
|
||||
## Download Management
|
||||
|
||||
@@ -80,25 +81,54 @@
|
||||
- `DELETE /api/subscriptions/tasks/:id/delete` - Delete a task record
|
||||
- `DELETE /api/subscriptions/tasks/clear-finished` - Clear all finished tasks
|
||||
|
||||
## Settings & System
|
||||
## Settings & Passwords
|
||||
|
||||
- `GET /api/settings` - Get application settings
|
||||
- `POST /api/settings` - Update application settings
|
||||
- Body: `{ [key: string]: any }` - Settings object
|
||||
- Supports: `visitorMode`, `cloudDriveEnabled`, `openListApiUrl`, `openListToken`, `openListPublicUrl`, `cloudDrivePath`, and other settings
|
||||
- `GET /api/settings/cloudflared/status` - Get Cloudflare Tunnel status
|
||||
- `GET /api/settings/password-enabled` - Check if password protection is enabled
|
||||
- `POST /api/settings/verify-password` - Verify login password
|
||||
- Body: `{ password: string }`
|
||||
- `POST /api/settings/reset-password` - Reset login password
|
||||
- Body: `{ oldPassword: string, newPassword: string }`
|
||||
- `POST /api/settings/migrate` - Migrate data from JSON to SQLite
|
||||
- `POST /api/settings/delete-legacy` - Delete legacy JSON data files
|
||||
- `POST /api/settings/format-filenames` - Format video filenames according to settings
|
||||
- `GET /api/settings/password-enabled` - Check if password protection is enabled
|
||||
- `GET /api/settings/reset-password-cooldown` - Get cooldown time for password reset
|
||||
- `POST /api/settings/verify-admin-password` - Verify admin password
|
||||
- Body: `{ password: string }`
|
||||
- `POST /api/settings/verify-visitor-password` - Verify visitor password
|
||||
- Body: `{ password: string }`
|
||||
- `POST /api/settings/verify-password` - Verify login password (deprecated)
|
||||
- Body: `{ password: string }`
|
||||
- `POST /api/settings/reset-password` - Reset login password
|
||||
- Body: `{ oldPassword: string, newPassword: string }`
|
||||
- `POST /api/settings/logout` - Logout current session
|
||||
|
||||
## Passkey Management
|
||||
|
||||
- `GET /api/settings/passkeys` - Get all registered passkeys
|
||||
- `GET /api/settings/passkeys/exists` - Check if any passkeys are registered
|
||||
- `POST /api/settings/passkeys/register` - Start passkey registration
|
||||
- `POST /api/settings/passkeys/register/verify` - Verify passkey registration
|
||||
- `POST /api/settings/passkeys/authenticate` - Start passkey authentication
|
||||
- `POST /api/settings/passkeys/authenticate/verify` - Verify passkey authentication
|
||||
- `DELETE /api/settings/passkeys` - Remove all passkeys
|
||||
|
||||
## Cookies
|
||||
|
||||
- `POST /api/settings/upload-cookies` - Upload cookies.txt for yt-dlp
|
||||
- Multipart form data: `file` (cookies.txt)
|
||||
- `POST /api/settings/delete-cookies` - Delete cookies.txt
|
||||
- `GET /api/settings/check-cookies` - Check if cookies.txt exists
|
||||
|
||||
## Task Hooks
|
||||
|
||||
- `GET /api/settings/hooks/status` - Get status of all hooks
|
||||
- `POST /api/settings/hooks/:name` - Upload a hook script
|
||||
- Multipart form data: `file` (script file)
|
||||
- Params: `name` (hook name, e.g., `task_success`)
|
||||
- `DELETE /api/settings/hooks/:name` - Delete a hook script
|
||||
|
||||
## Database Backups
|
||||
|
||||
- `GET /api/settings/export-database` - Export database as backup file
|
||||
- `POST /api/settings/import-database` - Import database from backup file
|
||||
- Multipart form data: `file` (database backup file)
|
||||
@@ -113,6 +143,15 @@
|
||||
|
||||
## Cloud Storage
|
||||
|
||||
- `GET /cloud/videos/:filename` - Proxy endpoint to stream videos from cloud storage (OpenList/Alist)
|
||||
- `GET /cloud/images/:filename` - Proxy endpoint to serve images from cloud storage (OpenList/Alist)
|
||||
- Note: These endpoints require cloud storage to be configured in settings
|
||||
- `GET /api/cloud/signed-url` - Get a signed URL for cloud storage
|
||||
- Query params: `filename` (required), `type` (optional: `video` or `thumbnail`)
|
||||
- `POST /api/cloud/sync` - Sync local videos to cloud storage (streams JSON lines)
|
||||
- `DELETE /api/cloud/thumbnail-cache` - Clear local thumbnail cache
|
||||
- `GET /api/cloud/thumbnail-cache/:filename` - Serve a cached cloud thumbnail
|
||||
- `GET /cloud/videos/:filename` - Redirect to signed URL for cloud videos
|
||||
- `GET /cloud/images/:filename` - Redirect to signed URL (or cached file) for cloud thumbnails
|
||||
|
||||
## System
|
||||
|
||||
- `GET /api/system/version` - Get current and latest version info
|
||||
- Returns: `{ currentVersion, latestVersion, releaseUrl, hasUpdate }`
|
||||
|
||||
@@ -2,269 +2,84 @@
|
||||
|
||||
```
|
||||
mytube/
|
||||
├── backend/ # Express.js backend (TypeScript)
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── __tests__/ # Test files
|
||||
│ │ │ ├── controllers/ # Controller tests
|
||||
│ │ │ ├── middleware/ # Middleware tests
|
||||
│ │ │ ├── services/ # Service tests
|
||||
│ │ │ └── utils/ # Utility tests
|
||||
│ │ ├── config/ # Configuration files
|
||||
│ │ │ └── paths.ts # Path configuration
|
||||
│ │ ├── controllers/ # Route controllers
|
||||
│ │ │ ├── cleanupController.ts # Cleanup operations
|
||||
├── backend/ # Express.js backend (TypeScript)
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── __tests__/ # Test files
|
||||
│ │ ├── config/ # Configuration (paths, etc.)
|
||||
│ │ ├── controllers/ # Route controllers
|
||||
│ │ │ ├── cleanupController.ts
|
||||
│ │ │ ├── cloudStorageController.ts
|
||||
│ │ │ ├── collectionController.ts
|
||||
│ │ │ ├── cookieController.ts
|
||||
│ │ │ ├── downloadController.ts
|
||||
│ │ │ ├── hookController.ts
|
||||
│ │ │ ├── passkeyController.ts
|
||||
│ │ │ ├── passwordController.ts
|
||||
│ │ │ ├── scanController.ts
|
||||
│ │ │ ├── settingsController.ts
|
||||
│ │ │ ├── subscriptionController.ts
|
||||
│ │ │ ├── systemController.ts
|
||||
│ │ │ ├── videoController.ts
|
||||
│ │ │ ├── videoDownloadController.ts
|
||||
│ │ │ └── videoMetadataController.ts
|
||||
│ │ ├── db/ # Database layer
|
||||
│ │ │ ├── index.ts # Database connection (Drizzle ORM)
|
||||
│ │ │ ├── migrate.ts # Migration runner
|
||||
│ │ │ └── schema.ts # Database schema definitions
|
||||
│ │ ├── errors/ # Custom error classes
|
||||
│ │ │ └── DownloadErrors.ts
|
||||
│ │ ├── middleware/ # Express middleware
|
||||
│ │ │ ├── errorHandler.ts # Error handling middleware
|
||||
│ │ │ ├── visitorModeMiddleware.ts # Visitor mode (read-only) middleware
|
||||
│ │ │ └── visitorModeSettingsMiddleware.ts # Visitor mode settings middleware
|
||||
│ │ ├── routes/ # API route definitions
|
||||
│ │ │ ├── api.ts # Main API routes
|
||||
│ │ │ └── settingsRoutes.ts # Settings-specific routes
|
||||
│ │ ├── scripts/ # Utility scripts
|
||||
│ │ │ ├── cleanVttFiles.ts
|
||||
│ │ │ └── rescanSubtitles.ts
|
||||
│ │ ├── services/ # Business logic services
|
||||
│ │ │ ├── downloaders/ # Downloader implementations
|
||||
│ │ │ │ ├── BaseDownloader.ts # Abstract base class
|
||||
│ │ │ │ ├── BilibiliDownloader.ts
|
||||
│ │ │ │ ├── MissAVDownloader.ts
|
||||
│ │ │ │ ├── YtDlpDownloader.ts
|
||||
│ │ │ │ ├── bilibili/ # Bilibili-specific modules
|
||||
│ │ │ │ │ ├── bilibiliApi.ts
|
||||
│ │ │ │ │ ├── bilibiliCollection.ts
|
||||
│ │ │ │ │ ├── bilibiliCookie.ts
|
||||
│ │ │ │ │ ├── bilibiliSubtitle.ts
|
||||
│ │ │ │ │ ├── bilibiliVideo.ts
|
||||
│ │ │ │ │ └── types.ts
|
||||
│ │ │ │ └── ytdlp/ # yt-dlp-specific modules
|
||||
│ │ │ │ ├── types.ts
|
||||
│ │ │ │ ├── ytdlpChannel.ts
|
||||
│ │ │ │ ├── ytdlpConfig.ts
|
||||
│ │ │ │ ├── ytdlpHelpers.ts
|
||||
│ │ │ │ ├── ytdlpMetadata.ts
|
||||
│ │ │ │ ├── ytdlpSearch.ts
|
||||
│ │ │ │ ├── ytdlpSubtitle.ts
|
||||
│ │ │ │ └── ytdlpVideo.ts
|
||||
│ │ │ ├── storageService/ # Modular storage service
|
||||
│ │ │ │ ├── index.ts # Main export file
|
||||
│ │ │ │ ├── types.ts # Type definitions
|
||||
│ │ │ │ ├── initialization.ts # Database initialization
|
||||
│ │ │ │ ├── downloadStatus.ts # Active/queued downloads
|
||||
│ │ │ │ ├── downloadHistory.ts # Download history
|
||||
│ │ │ │ ├── videoDownloadTracking.ts # Duplicate prevention
|
||||
│ │ │ │ ├── settings.ts # Application settings
|
||||
│ │ │ │ ├── videos.ts # Video CRUD operations
|
||||
│ │ │ │ ├── collections.ts # Collection operations
|
||||
│ │ │ │ └── fileHelpers.ts # File system utilities
|
||||
│ │ │ ├── CloudStorageService.ts
|
||||
│ │ │ ├── commentService.ts
|
||||
│ │ │ ├── downloadManager.ts # Download queue management
|
||||
│ │ │ ├── downloadService.ts
|
||||
│ │ │ ├── loginAttemptService.ts
|
||||
│ │ │ ├── metadataService.ts
|
||||
│ │ │ ├── migrationService.ts
|
||||
│ │ │ ├── storageService.ts # Legacy compatibility export
|
||||
│ │ │ ├── subscriptionService.ts
|
||||
│ │ │ ├── subtitleService.ts
|
||||
│ │ │ └── thumbnailService.ts
|
||||
│ │ ├── utils/ # Utility functions
|
||||
│ │ │ ├── bccToVtt.ts # Subtitle conversion
|
||||
│ │ │ ├── downloadUtils.ts
|
||||
│ │ │ ├── helpers.ts
|
||||
│ │ │ ├── logger.ts
|
||||
│ │ │ ├── progressTracker.ts
|
||||
│ │ │ ├── response.ts
|
||||
│ │ │ └── ytDlpUtils.ts
|
||||
│ │ ├── server.ts # Main server file
|
||||
│ │ └── version.ts # Version information
|
||||
│ ├── bgutil-ytdlp-pot-provider/ # PO Token provider plugin
|
||||
│ │ ├── plugin/ # Python plugin
|
||||
│ │ │ └── yt_dlp_plugins/
|
||||
│ │ └── server/ # TypeScript server
|
||||
│ │ └── src/
|
||||
│ ├── data/ # Data directory
|
||||
│ │ ├── mytube.db # SQLite database
|
||||
│ │ ├── cookies.txt # yt-dlp cookies (optional)
|
||||
│ │ └── login-attempts.json # Login attempt tracking
|
||||
│ ├── drizzle/ # Database migrations
|
||||
│ │ └── meta/ # Migration metadata
|
||||
│ ├── uploads/ # Uploaded files directory
|
||||
│ │ ├── videos/ # Downloaded videos
|
||||
│ │ ├── images/ # Downloaded thumbnails
|
||||
│ │ └── subtitles/ # Downloaded subtitles
|
||||
│ ├── dist/ # Compiled JavaScript
|
||||
│ ├── coverage/ # Test coverage reports
|
||||
│ ├── Dockerfile # Backend Docker image
|
||||
│ ├── drizzle.config.ts # Drizzle ORM configuration
|
||||
│ ├── nodemon.json # Nodemon configuration
|
||||
│ ├── package.json # Backend dependencies
|
||||
│ ├── tsconfig.json # TypeScript configuration
|
||||
│ └── vitest.config.ts # Vitest test configuration
|
||||
├── frontend/ # React.js frontend (Vite + TypeScript)
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── __tests__/ # Test files
|
||||
│ │ ├── assets/ # Static assets
|
||||
│ │ │ └── logo.svg
|
||||
│ │ ├── components/ # React components
|
||||
│ │ │ ├── Header/ # Header component group
|
||||
│ │ │ │ ├── ActionButtons.tsx
|
||||
│ │ │ │ ├── DownloadsMenu.tsx
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ ├── Logo.tsx
|
||||
│ │ │ │ ├── ManageMenu.tsx
|
||||
│ │ │ │ ├── MobileMenu.tsx
|
||||
│ │ │ │ ├── SearchInput.tsx
|
||||
│ │ │ │ └── types.ts
|
||||
│ │ │ ├── ManagePage/ # Management page components
|
||||
│ │ │ │ ├── CollectionsTable.tsx
|
||||
│ │ │ │ └── VideosTable.tsx
|
||||
│ │ │ ├── Settings/ # Settings page components
|
||||
│ │ │ │ ├── AdvancedSettings.tsx
|
||||
│ │ │ │ ├── CloudDriveSettings.tsx
|
||||
│ │ │ │ ├── CookieSettings.tsx
|
||||
│ │ │ │ ├── DatabaseSettings.tsx
|
||||
│ │ │ │ ├── DownloadSettings.tsx
|
||||
│ │ │ │ ├── GeneralSettings.tsx
|
||||
│ │ │ │ ├── SecuritySettings.tsx
|
||||
│ │ │ │ ├── TagsSettings.tsx
|
||||
│ │ │ │ ├── VideoDefaultSettings.tsx
|
||||
│ │ │ │ └── YtDlpSettings.tsx
|
||||
│ │ │ ├── VideoPlayer/ # Video player components
|
||||
│ │ │ │ ├── CommentsSection.tsx
|
||||
│ │ │ │ ├── UpNextSidebar.tsx
|
||||
│ │ │ │ ├── VideoControls.tsx
|
||||
│ │ │ │ ├── VideoInfo.tsx
|
||||
│ │ │ │ └── VideoInfo/ # Video info subcomponents
|
||||
│ │ │ │ ├── EditableTitle.tsx
|
||||
│ │ │ │ ├── VideoActionButtons.tsx
|
||||
│ │ │ │ ├── VideoAuthorInfo.tsx
|
||||
│ │ │ │ ├── VideoDescription.tsx
|
||||
│ │ │ │ ├── VideoMetadata.tsx
|
||||
│ │ │ │ ├── VideoRating.tsx
|
||||
│ │ │ │ └── VideoTags.tsx
|
||||
│ │ │ ├── AlertModal.tsx
|
||||
│ │ │ ├── AnimatedRoutes.tsx
|
||||
│ │ │ ├── AuthorsList.tsx
|
||||
│ │ │ ├── BatchDownloadModal.tsx
|
||||
│ │ │ ├── BilibiliPartsModal.tsx
|
||||
│ │ │ ├── CollectionCard.tsx
|
||||
│ │ │ ├── CollectionModal.tsx
|
||||
│ │ │ ├── Collections.tsx
|
||||
│ │ │ ├── ConfirmationModal.tsx
|
||||
│ │ │ ├── DeleteCollectionModal.tsx
|
||||
│ │ │ ├── Disclaimer.tsx
|
||||
│ │ │ ├── Footer.tsx
|
||||
│ │ │ ├── PageTransition.tsx
|
||||
│ │ │ ├── SubscribeModal.tsx
|
||||
│ │ │ ├── TagsList.tsx
|
||||
│ │ │ ├── UploadModal.tsx
|
||||
│ │ │ └── VideoCard.tsx
|
||||
│ │ ├── contexts/ # React contexts for state management
|
||||
│ │ │ ├── AuthContext.tsx
|
||||
│ │ │ ├── CollectionContext.tsx
|
||||
│ │ │ ├── DownloadContext.tsx
|
||||
│ │ │ ├── LanguageContext.tsx
|
||||
│ │ │ ├── SnackbarContext.tsx
|
||||
│ │ │ ├── ThemeContext.tsx
|
||||
│ │ │ └── VideoContext.tsx
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ │ ├── useDebounce.ts
|
||||
│ │ │ ├── useShareVideo.ts
|
||||
│ │ │ └── useVideoResolution.ts
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ │ ├── AuthorVideos.tsx
|
||||
│ │ │ ├── CollectionPage.tsx
|
||||
│ │ │ ├── DownloadPage/ # Download page components
|
||||
│ │ │ │ ├── ActiveDownloadsTab.tsx
|
||||
│ │ │ │ ├── CustomTabPanel.tsx
|
||||
│ │ │ │ ├── HistoryItem.tsx
|
||||
│ │ │ │ ├── HistoryTab.tsx
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ └── QueueTab.tsx
|
||||
│ │ │ ├── Home.tsx
|
||||
│ │ │ ├── InstructionPage.tsx
|
||||
│ │ │ ├── LoginPage.tsx
|
||||
│ │ │ ├── ManagePage.tsx
|
||||
│ │ │ ├── SearchPage.tsx
|
||||
│ │ │ ├── SearchResults.tsx
|
||||
│ │ │ ├── SettingsPage.tsx
|
||||
│ │ │ ├── SubscriptionsPage.tsx
|
||||
│ │ │ └── VideoPlayer.tsx
|
||||
│ │ ├── utils/ # Utilities and locales
|
||||
│ │ │ ├── locales/ # Internationalization files
|
||||
│ │ │ │ ├── ar.ts # Arabic
|
||||
│ │ │ │ ├── de.ts # German
|
||||
│ │ │ │ ├── en.ts # English
|
||||
│ │ │ │ ├── es.ts # Spanish
|
||||
│ │ │ │ ├── fr.ts # French
|
||||
│ │ │ │ ├── ja.ts # Japanese
|
||||
│ │ │ │ ├── ko.ts # Korean
|
||||
│ │ │ │ ├── pt.ts # Portuguese
|
||||
│ │ │ │ ├── ru.ts # Russian
|
||||
│ │ │ │ └── zh.ts # Chinese
|
||||
│ │ │ ├── consoleManager.ts
|
||||
│ │ │ ├── constants.ts
|
||||
│ │ │ ├── formatUtils.ts
|
||||
│ │ │ ├── recommendations.ts
|
||||
│ │ │ └── translations.ts
|
||||
│ │ ├── App.tsx # Main application component
|
||||
│ │ ├── App.css
|
||||
│ │ ├── index.css
|
||||
│ │ ├── main.tsx # Application entry point
|
||||
│ │ ├── setupTests.ts
|
||||
│ │ ├── theme.ts # Material-UI theme configuration
|
||||
│ │ ├── types.ts # TypeScript type definitions
|
||||
│ │ ├── version.ts # Version information
|
||||
│ │ └── vite-env.d.ts
|
||||
│ ├── dist/ # Production build output
|
||||
│ ├── public/ # Public static files
|
||||
│ ├── Dockerfile # Frontend Docker image
|
||||
│ ├── entrypoint.sh # Docker entrypoint script
|
||||
│ ├── eslint.config.js # ESLint configuration
|
||||
│ ├── index.html # HTML template
|
||||
│ ├── nginx.conf # Nginx configuration
|
||||
│ ├── package.json # Frontend dependencies
|
||||
│ ├── tsconfig.json # TypeScript configuration
|
||||
│ │ ├── db/ # Drizzle ORM + SQLite
|
||||
│ │ ├── errors/ # Custom error classes
|
||||
│ │ ├── middleware/ # Express middleware
|
||||
│ │ │ ├── authMiddleware.ts
|
||||
│ │ │ ├── roleBasedAuthMiddleware.ts
|
||||
│ │ │ ├── roleBasedSettingsMiddleware.ts
|
||||
│ │ │ └── errorHandler.ts
|
||||
│ │ ├── routes/ # Route definitions
|
||||
│ │ │ ├── api.ts # Main API routes
|
||||
│ │ │ └── settingsRoutes.ts # Settings-specific routes
|
||||
│ │ ├── scripts/ # Utility scripts (VTT cleanup, rescans)
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ │ ├── cloudStorage/ # Cloud storage helpers and cache
|
||||
│ │ │ ├── continuousDownload/ # Subscription task engine
|
||||
│ │ │ ├── downloaders/ # Platform downloaders (yt-dlp, Bilibili, MissAV)
|
||||
│ │ │ ├── storageService/ # Modular storage service
|
||||
│ │ │ └── *.ts # Other services (auth, metadata, etc.)
|
||||
│ │ ├── utils/ # Shared utilities
|
||||
│ │ ├── server.ts # Server bootstrap
|
||||
│ │ └── version.ts # Version info
|
||||
│ ├── bgutil-ytdlp-pot-provider/ # PO Token provider plugin
|
||||
│ ├── data/ # Runtime data (db, hooks, backups)
|
||||
│ ├── drizzle/ # Database migrations
|
||||
│ ├── uploads/ # Media storage (videos, images, subtitles, cache)
|
||||
│ ├── scripts/ # Maintenance scripts (reset-password, migrate, verify)
|
||||
│ ├── Dockerfile
|
||||
│ ├── drizzle.config.ts
|
||||
│ ├── nodemon.json
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── vitest.config.ts
|
||||
├── frontend/ # React frontend (Vite + TypeScript)
|
||||
│ ├── src/ # Source code
|
||||
│ │ ├── __tests__/ # Test files
|
||||
│ │ ├── assets/ # Static assets
|
||||
│ │ ├── components/ # UI components by feature
|
||||
│ │ ├── contexts/ # React Context state
|
||||
│ │ ├── hooks/ # Custom hooks (player, settings, data)
|
||||
│ │ ├── pages/ # Route pages
|
||||
│ │ ├── utils/ # API client, helpers, i18n
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── main.tsx
|
||||
│ │ ├── theme.ts
|
||||
│ │ └── version.ts
|
||||
│ ├── public/ # Public assets
|
||||
│ ├── Dockerfile
|
||||
│ ├── entrypoint.sh
|
||||
│ ├── nginx.conf
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── tsconfig.node.json
|
||||
│ └── vite.config.js # Vite configuration
|
||||
├── documents/ # Documentation
|
||||
│ ├── en/ # English documentation
|
||||
│ │ ├── api-endpoints.md
|
||||
│ │ ├── directory-structure.md
|
||||
│ │ ├── docker-guide.md
|
||||
│ │ └── getting-started.md
|
||||
│ └── zh/ # Chinese documentation
|
||||
│ ├── api-endpoints.md
|
||||
│ ├── directory-structure.md
|
||||
│ ├── docker-guide.md
|
||||
│ └── getting-started.md
|
||||
├── data/ # Root data directory (optional)
|
||||
│ └── mytube.db
|
||||
├── build-and-push.sh # Docker build and push script
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── CHANGELOG.md # Changelog
|
||||
├── CODE_OF_CONDUCT.md # Code of conduct
|
||||
├── CONTRIBUTING.md # Contributing guidelines
|
||||
├── LICENSE # MIT License
|
||||
├── README.md # English README
|
||||
├── README-zh.md # Chinese README
|
||||
├── RELEASING.md # Release process guide
|
||||
├── SECURITY.md # Security policy
|
||||
└── package.json # Root package.json for running both apps
|
||||
│ └── vite.config.js
|
||||
├── documents/ # Documentation (EN/ZH)
|
||||
├── docker-compose.yml # Compose for standard deployments
|
||||
├── docker-compose.host-network.yml # Host-network compose for OpenWrt/iStoreOS
|
||||
├── README.md
|
||||
├── README-zh.md
|
||||
└── package.json # Root scripts
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
@@ -273,28 +88,25 @@ mytube/
|
||||
|
||||
The backend follows a **layered architecture** pattern:
|
||||
|
||||
1. **Routes Layer** (`routes/`): Defines API endpoints and maps them to controllers
|
||||
2. **Controllers Layer** (`controllers/`): Handles HTTP requests/responses and delegates to services
|
||||
3. **Services Layer** (`services/`): Contains business logic
|
||||
- **Downloaders**: Abstract base class pattern for platform-specific downloaders
|
||||
- **Storage Service**: Modular service split into focused modules
|
||||
- **Supporting Services**: Download management, subscriptions, metadata, etc.
|
||||
4. **Database Layer** (`db/`): Drizzle ORM with SQLite for data persistence
|
||||
5. **Utils Layer** (`utils/`): Shared utility functions
|
||||
1. **Routes** (`routes/`): Define API endpoints and map them to controllers
|
||||
2. **Controllers** (`controllers/`): HTTP request/response handling
|
||||
3. **Services** (`services/`): Business logic (downloaders, storage, cloud, subscriptions)
|
||||
4. **Database** (`db/`): Drizzle ORM + SQLite
|
||||
5. **Utilities** (`utils/`): Shared helpers and infrastructure
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
The frontend follows a **component-based architecture**:
|
||||
|
||||
1. **Pages** (`pages/`): Top-level route components
|
||||
2. **Components** (`components/`): Reusable UI components organized by feature
|
||||
3. **Contexts** (`contexts/`): React Context API for global state management
|
||||
4. **Hooks** (`hooks/`): Custom React hooks for reusable logic
|
||||
5. **Utils** (`utils/`): Helper functions and internationalization
|
||||
2. **Components** (`components/`): Feature-oriented UI building blocks
|
||||
3. **Contexts** (`contexts/`): React Context global state
|
||||
4. **Hooks** (`hooks/`): Reusable logic and data access
|
||||
5. **Utils** (`utils/`): API client, formatting, and i18n helpers
|
||||
|
||||
### Database Schema
|
||||
|
||||
The application uses **SQLite** with **Drizzle ORM** for data persistence. Key tables include:
|
||||
Key tables include:
|
||||
|
||||
- `videos`: Video metadata and file paths
|
||||
- `collections`: Video collections/playlists
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Docker Deployment Guide for MyTube
|
||||
|
||||
This guide provides step-by-step instructions to deploy [MyTube](https://github.com/franklioxygen/MyTube "null") using Docker and Docker Compose. This setup is designed for standard environments (Linux, macOS, Windows) and modifies the original QNAP-specific configurations for general use.
|
||||
This guide provides step-by-step instructions to deploy [MyTube](https://github.com/franklioxygen/MyTube "null") using Docker and Docker Compose. The repository includes QNAP-oriented compose files; update the volume paths to match your environment or use the sample below.
|
||||
|
||||
> [!NOTE]
|
||||
> **Multi-Architecture Support:** The official images support both **amd64** (x86_64) and **arm64** (Apple Silicon, Raspberry Pi, etc.) architectures. Docker will automatically pull the correct image for your system.
|
||||
@@ -22,7 +22,7 @@ cd mytube-deploy
|
||||
|
||||
Create a file named `docker-compose.yml` inside your folder and paste the following content.
|
||||
|
||||
**Note:** This version uses standard relative paths (`./data`, `./uploads`) instead of the QNAP-specific paths found in the original repository.
|
||||
**Note:** This version uses standard relative paths (`./data`, `./uploads`). If you copy the repo’s `docker-compose.yml`, update the volume paths to match your host.
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
@@ -39,8 +39,6 @@ services:
|
||||
- mytube-network
|
||||
environment:
|
||||
- PORT=5551
|
||||
# Optional: Set a custom upload directory inside container if needed
|
||||
# - VIDEO_DIR=/app/uploads/videos
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./data:/app/data
|
||||
@@ -217,4 +215,10 @@ If you prefer to build the images yourself (e.g., to modify code), follow these
|
||||
2. Uncomment `network_mode: host` for both `backend` and `frontend`.
|
||||
3. Remove (or comment out) the `ports` and `networks` sections for both services.
|
||||
4. Set `NGINX_BACKEND_URL=http://localhost:5551` in the `frontend` environment variables.
|
||||
5. Restart containers: `docker-compose up -d`
|
||||
5. Restart containers: `docker-compose up -d`
|
||||
|
||||
Alternatively, the repo includes `docker-compose.host-network.yml` for host-network deployments:
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose.host-network.yml up -d
|
||||
```
|
||||
|
||||
@@ -52,23 +52,21 @@ Create a `.env` file in the `backend/` directory:
|
||||
|
||||
```env
|
||||
PORT=5551
|
||||
UPLOAD_DIR=uploads
|
||||
VIDEO_DIR=uploads/videos
|
||||
IMAGE_DIR=uploads/images
|
||||
SUBTITLES_DIR=uploads/subtitles
|
||||
DATA_DIR=data
|
||||
MAX_FILE_SIZE=500000000
|
||||
```
|
||||
|
||||
Data and uploads are stored under `backend/data` and `backend/uploads` by default (relative to the backend working directory).
|
||||
|
||||
#### Frontend Configuration
|
||||
|
||||
Create a `.env` file in the `frontend/` directory:
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5551/api
|
||||
VITE_BACKEND_URL=http://localhost:5551
|
||||
VITE_API_URL=/api
|
||||
VITE_BACKEND_URL=
|
||||
```
|
||||
|
||||
`backend/.env.example` is provided. Copy it to `backend/.env` and adjust as needed. The frontend ships with `frontend/.env`; use `frontend/.env.local` to override defaults.
|
||||
|
||||
### 4. Database Setup
|
||||
|
||||
The application uses **SQLite** with **Drizzle ORM**. The database will be automatically created and migrated on first startup:
|
||||
@@ -92,7 +90,7 @@ This will start:
|
||||
- **Frontend**: http://localhost:5556 (Vite dev server with hot reload)
|
||||
- **Backend API**: http://localhost:5551 (Express server with nodemon)
|
||||
|
||||
### Production Mode
|
||||
### Production Mode (Local)
|
||||
|
||||
Build and start in production mode:
|
||||
|
||||
@@ -100,10 +98,19 @@ Build and start in production mode:
|
||||
# Build frontend
|
||||
npm run build
|
||||
|
||||
# Start both services
|
||||
# Start backend
|
||||
cd backend
|
||||
npm run start
|
||||
|
||||
# In another terminal, preview the frontend build
|
||||
cd frontend
|
||||
npm run preview
|
||||
```
|
||||
|
||||
For Docker-based production, follow the [Docker Deployment Guide](docker-guide.md).
|
||||
|
||||
`npm run start` at the repo root is a convenience command that runs the backend start script and the frontend dev server together.
|
||||
|
||||
### Individual Services
|
||||
|
||||
You can also run services individually:
|
||||
@@ -126,9 +133,12 @@ From the root directory:
|
||||
|
||||
```bash
|
||||
npm run dev # Start both frontend and backend in development mode
|
||||
npm run start # Start both frontend and backend in production mode
|
||||
npm run start # Start backend + frontend dev server (convenience)
|
||||
npm run build # Build the frontend for production
|
||||
npm run install:all # Install dependencies for root, frontend, and backend
|
||||
npm run test # Run frontend + backend tests
|
||||
npm run test:frontend # Run frontend tests
|
||||
npm run test:backend # Run backend tests
|
||||
```
|
||||
|
||||
Backend-specific scripts (from `backend/` directory):
|
||||
@@ -140,6 +150,7 @@ npm run build # Compile TypeScript to JavaScript
|
||||
npm run test # Run tests with Vitest
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
npm run generate # Generate database migrations with Drizzle Kit
|
||||
npm run reset-password # Reset admin password via script
|
||||
```
|
||||
|
||||
Frontend-specific scripts (from `frontend/` directory):
|
||||
@@ -150,16 +161,18 @@ npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
npm run test # Run tests with Vitest
|
||||
npm run test:coverage # Run tests with coverage report
|
||||
```
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
1. **Access the Application**: Open http://localhost:5556 in your browser
|
||||
|
||||
2. **Set Up Password Protection** (Optional):
|
||||
2. **Set Up Login Protection** (Optional):
|
||||
|
||||
- Go to Settings → Security
|
||||
- Enable password protection and set a password
|
||||
- Enable login and set an admin password
|
||||
- Optionally register a passkey (WebAuthn)
|
||||
|
||||
3. **Configure Download Settings**:
|
||||
|
||||
@@ -183,12 +196,11 @@ npm run test # Run tests with Vitest
|
||||
- Test the connection to verify settings
|
||||
- Note: When enabled, videos will be automatically uploaded to cloud storage after download, and local files will be deleted
|
||||
|
||||
6. **Configure Visitor Mode** (Optional):
|
||||
6. **Configure Visitor User** (Optional):
|
||||
|
||||
- Go to Settings → General Settings
|
||||
- Enable "Visitor Mode (Read-only)" to allow viewing videos without modification capabilities
|
||||
- When enabled, all write operations (downloads, deletions, edits) are blocked
|
||||
- To disable visitor mode, you'll need to enter the website password
|
||||
- Go to Settings → Security
|
||||
- Enable "Visitor User" to allow read-only access
|
||||
- Set a visitor password for the read-only role
|
||||
|
||||
7. **Start Downloading**:
|
||||
- Enter a video URL in the download input
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
# Task Hooks Guide
|
||||
|
||||
MyTube allows you to execute custom shell scripts at various stages of a download task's lifecycle. This feature is powerful for integrating MyTube with other systems, performing post-processing on downloaded files, or sending notifications.
|
||||
MyTube lets you execute custom shell scripts at different stages of a download task. This is useful for post-processing, notifications, or external integrations.
|
||||
|
||||
## available Hooks
|
||||
|
||||
You can configure commands for the following events:
|
||||
## Available Hooks
|
||||
|
||||
| Hook Name | Trigger Point | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **Before Task Start** (`task_before_start`) | Before download begins | Executed immediately before the download process starts. Useful for setup or validation. |
|
||||
| **Task Success** (`task_success`) | After successful download | Executed after the file is successfully downloaded / merged, but **before** it is uploaded to cloud storage (if enabled) or deleted. This is the ideal place for file processing. |
|
||||
| **Task Failed** (`task_fail`) | On task failure | Executed if the download fails for any reason. |
|
||||
| **Task Cancelled** (`task_cancel`) | On task cancellation | Executed when a user manually cancels a running task. |
|
||||
| **Before Task Start** (`task_before_start`) | Before download begins | Runs right before the download starts. |
|
||||
| **Task Success** (`task_success`) | After successful download | Runs after download/merge completes but **before** cloud upload (if enabled) or deletion. |
|
||||
| **Task Failed** (`task_fail`) | On task failure | Runs when a download fails. |
|
||||
| **Task Cancelled** (`task_cancel`) | On task cancellation | Runs when a user cancels a running task. |
|
||||
|
||||
Hook scripts are stored under `backend/data/hooks` by default.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
When a hook command is executed, the following environment variables are injected into the shell environment, providing context about the task:
|
||||
|
||||
| Variable | Description | Example |
|
||||
| :--- | :--- | :--- |
|
||||
| `MYTUBE_TASK_ID` | The unique identifier of the task | `335e98f0-15cb-46a4-846d-9d4351368923` |
|
||||
| `MYTUBE_TASK_TITLE` | The title of the video/task | `Awesome Video Title` |
|
||||
| `MYTUBE_SOURCE_URL` | The original URL of the video | `https://www.youtube.com/watch?v=...` |
|
||||
| `MYTUBE_TASK_STATUS` | The current status of the task | `success`, `fail`, `cancelled` |
|
||||
| `MYTUBE_VIDEO_PATH` | Absolute path to the downloaded video file | `/app/downloads/video.mp4` |
|
||||
| `MYTUBE_THUMBNAIL_PATH` | Absolute path to the thumbnail file | `/app/downloads/video.jpg` |
|
||||
| `MYTUBE_TASK_ID` | Unique task ID | `335e98f0-15cb-46a4-846d-9d4351368923` |
|
||||
| `MYTUBE_TASK_TITLE` | Video/task title | `Awesome Video Title` |
|
||||
| `MYTUBE_SOURCE_URL` | Original video URL | `https://www.youtube.com/watch?v=...` |
|
||||
| `MYTUBE_TASK_STATUS` | Current status | `start`, `success`, `fail`, `cancel` |
|
||||
| `MYTUBE_VIDEO_PATH` | Absolute path to video file | `/app/uploads/videos/video.mp4` |
|
||||
| `MYTUBE_THUMBNAIL_PATH` | Absolute path to thumbnail | `/app/uploads/images/video.jpg` |
|
||||
| `MYTUBE_ERROR` | Error message (only for `task_fail`) | `Network timeout` |
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure hooks in the web interface:
|
||||
1. Go to **Settings**.
|
||||
2. Scroll down to **Advanced Settings**.
|
||||
3. Find the **Task Hooks** section.
|
||||
4. Upload your `.sh` scripts for the desired events.
|
||||
5. You can Delete or Re-upload scripts as needed.
|
||||
2. Open **Advanced Settings**.
|
||||
3. Find **Task Hooks**.
|
||||
4. Upload `.sh` scripts for the desired events.
|
||||
5. Delete or re-upload as needed.
|
||||
|
||||
Hooks are executed with `bash` and are made executable on upload.
|
||||
|
||||
## Viewing Logs
|
||||
|
||||
Any output from your script (stdout/stderr) will be captured and logged to the MyTube server console.
|
||||
Any output from your script (stdout/stderr) is captured and logged by the backend.
|
||||
- Standard output (`echo "..."`) is logged as `INFO`.
|
||||
- Standard error (`>&2 echo "..."`) is logged as `WARN`.
|
||||
|
||||
@@ -69,7 +68,7 @@ curl -d "MyTube Download Failed: $MYTUBE_TASK_TITLE - $MYTUBE_ERROR" https://ntf
|
||||
```
|
||||
|
||||
### 3. File Post-Processing
|
||||
Run a python script to process the file.
|
||||
Run a Python script to process the file.
|
||||
**Hook:** `task_success`
|
||||
```bash
|
||||
#!/bin/bash
|
||||
@@ -79,7 +78,7 @@ python3 /path/to/process_video.py "$MYTUBE_VIDEO_PATH"
|
||||
## Security Warning
|
||||
|
||||
> [!WARNING]
|
||||
> Hook commands are executed with the same permissions as the MyTube backend server.
|
||||
> Hook commands run with the same permissions as the MyTube backend.
|
||||
> - Be careful when using commands that modify or delete files.
|
||||
> - Do not copy-paste scripts from untrusted sources.
|
||||
> - Do not copy/paste scripts from untrusted sources.
|
||||
> - Ensure your scripts handle errors gracefully.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# API 端点
|
||||
|
||||
除特殊说明外,所有 API 路由均挂载在 `/api` 下。
|
||||
|
||||
## 视频下载与搜索
|
||||
|
||||
- `GET /api/search` - 在线搜索视频 (YouTube)
|
||||
- 查询参数: `query` (必需), `limit` (可选, 默认: 8), `offset` (可选, 默认: 1)
|
||||
- `POST /api/download` - 从支持的平台下载视频
|
||||
- 请求体: `{ url: string, ...options }`
|
||||
- 支持: YouTube, Bilibili, MissAV 以及所有 yt-dlp 支持的网站
|
||||
- 请求体: `{ youtubeUrl: string, ...options }`
|
||||
- 支持: YouTube、Bilibili、MissAV 以及所有 yt-dlp 支持的网站
|
||||
- `GET /api/check-video-download` - 检查视频是否已下载
|
||||
- 查询参数: `url` (必需)
|
||||
- 返回: `{ found: boolean, status: 'exists' | 'deleted', videoId?: string, ... }`
|
||||
@@ -15,10 +18,11 @@
|
||||
- 查询参数: `url` (必需)
|
||||
- `GET /api/check-playlist` - 检查 URL 是否为受支持的播放列表
|
||||
- 查询参数: `url` (必需)
|
||||
- `GET /api/download-status` - 获取活动下载的状态
|
||||
- 返回: `{ active: [], queued: [] }`
|
||||
- `GET /api/download-status` - 获取下载状态
|
||||
- 返回: `{ activeDownloads: DownloadInfo[], queuedDownloads: DownloadInfo[] }`
|
||||
|
||||
## 视频管理
|
||||
|
||||
- `POST /api/upload` - 上传本地视频文件
|
||||
- 多部分表单数据: `video` (文件)
|
||||
- 自动生成缩略图
|
||||
@@ -38,9 +42,9 @@
|
||||
- `GET /api/videos/author-channel-url` - 获取视频的作者频道 URL
|
||||
- 查询参数: `sourceUrl` (必需)
|
||||
- 返回: `{ success: boolean, channelUrl: string | null }`
|
||||
- 首先检查数据库,如果未找到则从 YouTube/Bilibili API 获取
|
||||
|
||||
## 下载管理
|
||||
|
||||
- `POST /api/downloads/cancel/:id` - 取消活动下载
|
||||
- `DELETE /api/downloads/queue/:id` - 从队列中移除下载
|
||||
- `DELETE /api/downloads/queue` - 清空整个下载队列
|
||||
@@ -50,6 +54,7 @@
|
||||
- `DELETE /api/downloads/history` - 清空整个下载历史
|
||||
|
||||
## 收藏夹
|
||||
|
||||
- `GET /api/collections` - 获取所有收藏夹
|
||||
- `POST /api/collections` - 创建新收藏夹
|
||||
- 请求体: `{ name: string, videoIds?: string[] }`
|
||||
@@ -58,6 +63,7 @@
|
||||
- `DELETE /api/collections/:id` - 删除收藏夹
|
||||
|
||||
## 订阅
|
||||
|
||||
- `GET /api/subscriptions` - 获取所有订阅
|
||||
- `POST /api/subscriptions` - 创建新订阅
|
||||
- 请求体: `{ authorUrl: string, interval: number, platform?: string }`
|
||||
@@ -70,30 +76,60 @@
|
||||
- `GET /api/subscriptions/tasks` - 获取所有持续下载任务
|
||||
- 查询参数: `page` (可选), `limit` (可选)
|
||||
- `POST /api/subscriptions/tasks/playlist` - 创建新的播放列表下载任务
|
||||
- Body: `{ url: string, ...options }`
|
||||
- 请求体: `{ url: string, ...options }`
|
||||
- `DELETE /api/subscriptions/tasks/:id` - 取消持续下载任务
|
||||
- `DELETE /api/subscriptions/tasks/:id/delete` - 删除任务记录
|
||||
- `DELETE /api/subscriptions/tasks/clear-finished` - 清除所有已完成的任务
|
||||
|
||||
## 设置与系统
|
||||
## 设置与密码
|
||||
|
||||
- `GET /api/settings` - 获取应用设置
|
||||
- `POST /api/settings` - 更新应用设置
|
||||
- 请求体: `{ [key: string]: any }` - 设置对象
|
||||
- 支持: `visitorMode`, `cloudDriveEnabled`, `openListApiUrl`, `openListToken`, `openListPublicUrl`, `cloudDrivePath` 及其他设置
|
||||
- `GET /api/settings/cloudflared/status` - 获取 Cloudflare Tunnel 状态
|
||||
- `GET /api/settings/password-enabled` - 检查是否启用了密码保护
|
||||
- `POST /api/settings/verify-password` - 验证登录密码
|
||||
- 请求体: `{ password: string }`
|
||||
- `POST /api/settings/reset-password` - 重置登录密码
|
||||
- 请求体: `{ oldPassword: string, newPassword: string }`
|
||||
- `POST /api/settings/migrate` - 从 JSON 迁移数据到 SQLite
|
||||
- `POST /api/settings/delete-legacy` - 删除旧的 JSON 数据文件
|
||||
- `POST /api/settings/format-filenames` - 根据设置格式化视频文件名
|
||||
- `GET /api/settings/password-enabled` - 检查是否启用了密码保护
|
||||
- `GET /api/settings/reset-password-cooldown` - 获取密码重置冷却时间
|
||||
- `POST /api/settings/verify-admin-password` - 验证管理员密码
|
||||
- 请求体: `{ password: string }`
|
||||
- `POST /api/settings/verify-visitor-password` - 验证访客密码
|
||||
- 请求体: `{ password: string }`
|
||||
- `POST /api/settings/verify-password` - 验证登录密码 (已废弃)
|
||||
- 请求体: `{ password: string }`
|
||||
- `POST /api/settings/reset-password` - 重置登录密码
|
||||
- 请求体: `{ oldPassword: string, newPassword: string }`
|
||||
- `POST /api/settings/logout` - 退出当前会话
|
||||
|
||||
## 通行密钥管理
|
||||
|
||||
- `GET /api/settings/passkeys` - 获取所有注册的通行密钥
|
||||
- `GET /api/settings/passkeys/exists` - 检查是否已注册任何通行密钥
|
||||
- `POST /api/settings/passkeys/register` - 开始通行密钥注册
|
||||
- `POST /api/settings/passkeys/register/verify` - 验证通行密钥注册
|
||||
- `POST /api/settings/passkeys/authenticate` - 开始通行密钥认证
|
||||
- `POST /api/settings/passkeys/authenticate/verify` - 验证通行密钥认证
|
||||
- `DELETE /api/settings/passkeys` - 删除所有通行密钥
|
||||
|
||||
## Cookies
|
||||
|
||||
- `POST /api/settings/upload-cookies` - 上传 cookies.txt 以供 yt-dlp 使用
|
||||
- 多部分表单数据: `file` (cookies.txt)
|
||||
- `POST /api/settings/delete-cookies` - 删除 cookies.txt
|
||||
- `GET /api/settings/check-cookies` - 检查 cookies.txt 是否存在
|
||||
- `GET /api/settings/export-database` - 导出数据库作为备份文件
|
||||
|
||||
## 任务钩子
|
||||
|
||||
- `GET /api/settings/hooks/status` - 获取所有钩子的状态
|
||||
- `POST /api/settings/hooks/:name` - 上传钩子脚本
|
||||
- 多部分表单数据: `file` (脚本文件)
|
||||
- 参数: `name` (钩子名称, 例如 `task_success`)
|
||||
- `DELETE /api/settings/hooks/:name` - 删除钩子脚本
|
||||
|
||||
## 数据库备份
|
||||
|
||||
- `GET /api/settings/export-database` - 导出数据库备份文件
|
||||
- `POST /api/settings/import-database` - 从备份文件导入数据库
|
||||
- 多部分表单数据: `file` (数据库备份文件)
|
||||
- `GET /api/settings/last-backup-info` - 获取最后一个数据库备份的信息
|
||||
@@ -101,10 +137,21 @@
|
||||
- `POST /api/settings/cleanup-backup-databases` - 清理旧的备份数据库文件
|
||||
|
||||
## 文件管理
|
||||
|
||||
- `POST /api/scan-files` - 扫描上传目录中的现有视频文件
|
||||
- `POST /api/cleanup-temp-files` - 清理临时下载文件
|
||||
|
||||
## 云存储
|
||||
- `GET /cloud/videos/:filename` - 代理端点,用于从云存储(OpenList/Alist)流式传输视频
|
||||
- `GET /cloud/images/:filename` - 代理端点,用于从云存储(OpenList/Alist)提供图像
|
||||
- 注意:这些端点需要在设置中配置云存储
|
||||
|
||||
- `GET /api/cloud/signed-url` - 获取云存储签名 URL
|
||||
- 查询参数: `filename` (必需), `type` (可选: `video` 或 `thumbnail`)
|
||||
- `POST /api/cloud/sync` - 同步本地视频到云存储 (以 JSON 行流式返回进度)
|
||||
- `DELETE /api/cloud/thumbnail-cache` - 清空缩略图缓存
|
||||
- `GET /api/cloud/thumbnail-cache/:filename` - 访问缓存的云端缩略图
|
||||
- `GET /cloud/videos/:filename` - 重定向到云端视频签名 URL
|
||||
- `GET /cloud/images/:filename` - 重定向到云端缩略图签名 URL(或本地缓存)
|
||||
|
||||
## 系统
|
||||
|
||||
- `GET /api/system/version` - 获取当前版本与最新版本信息
|
||||
- 返回: `{ currentVersion, latestVersion, releaseUrl, hasUpdate }`
|
||||
|
||||
@@ -2,305 +2,117 @@
|
||||
|
||||
```
|
||||
mytube/
|
||||
├── backend/ # Express.js 后端 (TypeScript)
|
||||
│ ├── src/ # 源代码
|
||||
│ │ ├── __tests__/ # 测试文件
|
||||
│ │ │ ├── controllers/ # 控制器测试
|
||||
│ │ │ ├── middleware/ # 中间件测试
|
||||
│ │ │ ├── services/ # 服务测试
|
||||
│ │ │ └── utils/ # 工具测试
|
||||
│ │ ├── config/ # 配置文件
|
||||
│ │ │ └── paths.ts # 路径配置
|
||||
│ │ ├── controllers/ # 路由控制器
|
||||
│ │ │ ├── cleanupController.ts # 清理操作
|
||||
├── backend/ # Express.js 后端 (TypeScript)
|
||||
│ ├── src/ # 源代码
|
||||
│ │ ├── __tests__/ # 测试文件
|
||||
│ │ ├── config/ # 配置(路径等)
|
||||
│ │ ├── controllers/ # 路由控制器
|
||||
│ │ │ ├── cleanupController.ts
|
||||
│ │ │ ├── cloudStorageController.ts
|
||||
│ │ │ ├── collectionController.ts
|
||||
│ │ │ ├── cookieController.ts
|
||||
│ │ │ ├── downloadController.ts
|
||||
│ │ │ ├── hookController.ts
|
||||
│ │ │ ├── passkeyController.ts
|
||||
│ │ │ ├── passwordController.ts
|
||||
│ │ │ ├── scanController.ts
|
||||
│ │ │ ├── settingsController.ts
|
||||
│ │ │ ├── subscriptionController.ts
|
||||
│ │ │ ├── systemController.ts
|
||||
│ │ │ ├── videoController.ts
|
||||
│ │ │ ├── videoDownloadController.ts
|
||||
│ │ │ └── videoMetadataController.ts
|
||||
│ │ ├── db/ # 数据库层
|
||||
│ │ │ ├── index.ts # 数据库连接 (Drizzle ORM)
|
||||
│ │ │ ├── migrate.ts # 迁移运行器
|
||||
│ │ │ └── schema.ts # 数据库模式定义
|
||||
│ │ ├── errors/ # 自定义错误类
|
||||
│ │ │ └── DownloadErrors.ts
|
||||
│ │ ├── middleware/ # Express 中间件
|
||||
│ │ │ ├── errorHandler.ts # 错误处理中间件
|
||||
│ │ │ ├── visitorModeMiddleware.ts # 访客模式(只读)中间件
|
||||
│ │ │ └── visitorModeSettingsMiddleware.ts # 访客模式设置中间件
|
||||
│ │ ├── routes/ # API 路由定义
|
||||
│ │ │ ├── api.ts # 主 API 路由
|
||||
│ │ │ └── settingsRoutes.ts # 设置相关路由
|
||||
│ │ ├── scripts/ # 实用脚本
|
||||
│ │ │ ├── cleanVttFiles.ts
|
||||
│ │ │ └── rescanSubtitles.ts
|
||||
│ │ ├── services/ # 业务逻辑服务
|
||||
│ │ │ ├── downloaders/ # 下载器实现
|
||||
│ │ │ │ ├── BaseDownloader.ts # 抽象基类
|
||||
│ │ │ │ ├── BilibiliDownloader.ts
|
||||
│ │ │ │ ├── MissAVDownloader.ts
|
||||
│ │ │ │ ├── YtDlpDownloader.ts
|
||||
│ │ │ │ ├── bilibili/ # Bilibili 特定模块
|
||||
│ │ │ │ │ ├── bilibiliApi.ts
|
||||
│ │ │ │ │ ├── bilibiliCollection.ts
|
||||
│ │ │ │ │ ├── bilibiliCookie.ts
|
||||
│ │ │ │ │ ├── bilibiliSubtitle.ts
|
||||
│ │ │ │ │ ├── bilibiliVideo.ts
|
||||
│ │ │ │ │ └── types.ts
|
||||
│ │ │ │ └── ytdlp/ # yt-dlp 特定模块
|
||||
│ │ │ │ ├── types.ts
|
||||
│ │ │ │ ├── ytdlpChannel.ts
|
||||
│ │ │ │ ├── ytdlpConfig.ts
|
||||
│ │ │ │ ├── ytdlpHelpers.ts
|
||||
│ │ │ │ ├── ytdlpMetadata.ts
|
||||
│ │ │ │ ├── ytdlpSearch.ts
|
||||
│ │ │ │ ├── ytdlpSubtitle.ts
|
||||
│ │ │ │ └── ytdlpVideo.ts
|
||||
│ │ │ ├── storageService/ # 模块化存储服务
|
||||
│ │ │ │ ├── index.ts # 主导出文件
|
||||
│ │ │ │ ├── types.ts # 类型定义
|
||||
│ │ │ │ ├── initialization.ts # 数据库初始化
|
||||
│ │ │ │ ├── downloadStatus.ts # 活动/队列下载
|
||||
│ │ │ │ ├── downloadHistory.ts # 下载历史
|
||||
│ │ │ │ ├── videoDownloadTracking.ts # 重复下载预防
|
||||
│ │ │ │ ├── settings.ts # 应用设置
|
||||
│ │ │ │ ├── videos.ts # 视频 CRUD 操作
|
||||
│ │ │ │ ├── collections.ts # 收藏夹操作
|
||||
│ │ │ │ └── fileHelpers.ts # 文件系统工具
|
||||
│ │ │ ├── CloudStorageService.ts
|
||||
│ │ │ ├── commentService.ts
|
||||
│ │ │ ├── downloadManager.ts # 下载队列管理
|
||||
│ │ │ ├── downloadService.ts
|
||||
│ │ │ ├── loginAttemptService.ts
|
||||
│ │ │ ├── metadataService.ts
|
||||
│ │ │ ├── migrationService.ts
|
||||
│ │ │ ├── storageService.ts # 向后兼容导出
|
||||
│ │ │ ├── subscriptionService.ts
|
||||
│ │ │ ├── subtitleService.ts
|
||||
│ │ │ └── thumbnailService.ts
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ │ ├── bccToVtt.ts # 字幕转换
|
||||
│ │ │ ├── downloadUtils.ts
|
||||
│ │ │ ├── helpers.ts
|
||||
│ │ │ ├── logger.ts
|
||||
│ │ │ ├── progressTracker.ts
|
||||
│ │ │ ├── response.ts
|
||||
│ │ │ └── ytDlpUtils.ts
|
||||
│ │ ├── server.ts # 主服务器文件
|
||||
│ │ └── version.ts # 版本信息
|
||||
│ ├── bgutil-ytdlp-pot-provider/ # PO Token 提供者插件
|
||||
│ │ ├── plugin/ # Python 插件
|
||||
│ │ │ └── yt_dlp_plugins/
|
||||
│ │ └── server/ # TypeScript 服务器
|
||||
│ │ └── src/
|
||||
│ ├── data/ # 数据目录
|
||||
│ │ ├── mytube.db # SQLite 数据库
|
||||
│ │ ├── cookies.txt # yt-dlp cookies (可选)
|
||||
│ │ └── login-attempts.json # 登录尝试跟踪
|
||||
│ ├── drizzle/ # 数据库迁移
|
||||
│ │ └── meta/ # 迁移元数据
|
||||
│ ├── uploads/ # 上传文件目录
|
||||
│ │ ├── videos/ # 下载的视频
|
||||
│ │ ├── images/ # 下载的缩略图
|
||||
│ │ └── subtitles/ # 下载的字幕
|
||||
│ ├── dist/ # 编译后的 JavaScript
|
||||
│ ├── coverage/ # 测试覆盖率报告
|
||||
│ ├── Dockerfile # 后端 Docker 镜像
|
||||
│ ├── drizzle.config.ts # Drizzle ORM 配置
|
||||
│ ├── nodemon.json # Nodemon 配置
|
||||
│ ├── package.json # 后端依赖
|
||||
│ ├── tsconfig.json # TypeScript 配置
|
||||
│ └── vitest.config.ts # Vitest 测试配置
|
||||
├── frontend/ # React.js 前端 (Vite + TypeScript)
|
||||
│ ├── src/ # 源代码
|
||||
│ │ ├── __tests__/ # 测试文件
|
||||
│ │ ├── assets/ # 静态资源
|
||||
│ │ │ └── logo.svg
|
||||
│ │ ├── components/ # React 组件
|
||||
│ │ │ ├── Header/ # 头部组件组
|
||||
│ │ │ │ ├── ActionButtons.tsx
|
||||
│ │ │ │ ├── DownloadsMenu.tsx
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ ├── Logo.tsx
|
||||
│ │ │ │ ├── ManageMenu.tsx
|
||||
│ │ │ │ ├── MobileMenu.tsx
|
||||
│ │ │ │ ├── SearchInput.tsx
|
||||
│ │ │ │ └── types.ts
|
||||
│ │ │ ├── ManagePage/ # 管理页面组件
|
||||
│ │ │ │ ├── CollectionsTable.tsx
|
||||
│ │ │ │ └── VideosTable.tsx
|
||||
│ │ │ ├── Settings/ # 设置页面组件
|
||||
│ │ │ │ ├── AdvancedSettings.tsx
|
||||
│ │ │ │ ├── CloudDriveSettings.tsx
|
||||
│ │ │ │ ├── CookieSettings.tsx
|
||||
│ │ │ │ ├── DatabaseSettings.tsx
|
||||
│ │ │ │ ├── DownloadSettings.tsx
|
||||
│ │ │ │ ├── GeneralSettings.tsx
|
||||
│ │ │ │ ├── SecuritySettings.tsx
|
||||
│ │ │ │ ├── TagsSettings.tsx
|
||||
│ │ │ │ ├── VideoDefaultSettings.tsx
|
||||
│ │ │ │ └── YtDlpSettings.tsx
|
||||
│ │ │ ├── VideoPlayer/ # 视频播放器组件
|
||||
│ │ │ │ ├── CommentsSection.tsx
|
||||
│ │ │ │ ├── UpNextSidebar.tsx
|
||||
│ │ │ │ ├── VideoControls.tsx
|
||||
│ │ │ │ ├── VideoInfo.tsx
|
||||
│ │ │ │ └── VideoInfo/ # 视频信息子组件
|
||||
│ │ │ │ ├── EditableTitle.tsx
|
||||
│ │ │ │ ├── VideoActionButtons.tsx
|
||||
│ │ │ │ ├── VideoAuthorInfo.tsx
|
||||
│ │ │ │ ├── VideoDescription.tsx
|
||||
│ │ │ │ ├── VideoMetadata.tsx
|
||||
│ │ │ │ ├── VideoRating.tsx
|
||||
│ │ │ │ └── VideoTags.tsx
|
||||
│ │ │ ├── AlertModal.tsx
|
||||
│ │ │ ├── AnimatedRoutes.tsx
|
||||
│ │ │ ├── AuthorsList.tsx
|
||||
│ │ │ ├── BatchDownloadModal.tsx
|
||||
│ │ │ ├── BilibiliPartsModal.tsx
|
||||
│ │ │ ├── CollectionCard.tsx
|
||||
│ │ │ ├── CollectionModal.tsx
|
||||
│ │ │ ├── Collections.tsx
|
||||
│ │ │ ├── ConfirmationModal.tsx
|
||||
│ │ │ ├── DeleteCollectionModal.tsx
|
||||
│ │ │ ├── Disclaimer.tsx
|
||||
│ │ │ ├── Footer.tsx
|
||||
│ │ │ ├── PageTransition.tsx
|
||||
│ │ │ ├── SubscribeModal.tsx
|
||||
│ │ │ ├── TagsList.tsx
|
||||
│ │ │ ├── UploadModal.tsx
|
||||
│ │ │ └── VideoCard.tsx
|
||||
│ │ ├── contexts/ # React 上下文用于状态管理
|
||||
│ │ │ ├── AuthContext.tsx
|
||||
│ │ │ ├── CollectionContext.tsx
|
||||
│ │ │ ├── DownloadContext.tsx
|
||||
│ │ │ ├── LanguageContext.tsx
|
||||
│ │ │ ├── SnackbarContext.tsx
|
||||
│ │ │ ├── ThemeContext.tsx
|
||||
│ │ │ └── VideoContext.tsx
|
||||
│ │ ├── hooks/ # 自定义 React hooks
|
||||
│ │ │ ├── useDebounce.ts
|
||||
│ │ │ ├── useShareVideo.ts
|
||||
│ │ │ └── useVideoResolution.ts
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ │ ├── AuthorVideos.tsx
|
||||
│ │ │ ├── CollectionPage.tsx
|
||||
│ │ │ ├── DownloadPage/ # 下载页面组件
|
||||
│ │ │ │ ├── ActiveDownloadsTab.tsx
|
||||
│ │ │ │ ├── CustomTabPanel.tsx
|
||||
│ │ │ │ ├── HistoryItem.tsx
|
||||
│ │ │ │ ├── HistoryTab.tsx
|
||||
│ │ │ │ ├── index.tsx
|
||||
│ │ │ │ └── QueueTab.tsx
|
||||
│ │ │ ├── Home.tsx
|
||||
│ │ │ ├── InstructionPage.tsx
|
||||
│ │ │ ├── LoginPage.tsx
|
||||
│ │ │ ├── ManagePage.tsx
|
||||
│ │ │ ├── SearchPage.tsx
|
||||
│ │ │ ├── SearchResults.tsx
|
||||
│ │ │ ├── SettingsPage.tsx
|
||||
│ │ │ ├── SubscriptionsPage.tsx
|
||||
│ │ │ └── VideoPlayer.tsx
|
||||
│ │ ├── utils/ # 工具和多语言
|
||||
│ │ │ ├── locales/ # 国际化文件
|
||||
│ │ │ │ ├── ar.ts # 阿拉伯语
|
||||
│ │ │ │ ├── de.ts # 德语
|
||||
│ │ │ │ ├── en.ts # 英语
|
||||
│ │ │ │ ├── es.ts # 西班牙语
|
||||
│ │ │ │ ├── fr.ts # 法语
|
||||
│ │ │ │ ├── ja.ts # 日语
|
||||
│ │ │ │ ├── ko.ts # 韩语
|
||||
│ │ │ │ ├── pt.ts # 葡萄牙语
|
||||
│ │ │ │ ├── ru.ts # 俄语
|
||||
│ │ │ │ └── zh.ts # 中文
|
||||
│ │ │ ├── consoleManager.ts
|
||||
│ │ │ ├── constants.ts
|
||||
│ │ │ ├── formatUtils.ts
|
||||
│ │ │ ├── recommendations.ts
|
||||
│ │ │ └── translations.ts
|
||||
│ │ ├── App.tsx # 主应用组件
|
||||
│ │ ├── App.css
|
||||
│ │ ├── index.css
|
||||
│ │ ├── main.tsx # 应用入口点
|
||||
│ │ ├── setupTests.ts
|
||||
│ │ ├── theme.ts # Material-UI 主题配置
|
||||
│ │ ├── types.ts # TypeScript 类型定义
|
||||
│ │ ├── version.ts # 版本信息
|
||||
│ │ └── vite-env.d.ts
|
||||
│ ├── dist/ # 生产构建输出
|
||||
│ ├── public/ # 公共静态文件
|
||||
│ ├── Dockerfile # 前端 Docker 镜像
|
||||
│ ├── entrypoint.sh # Docker 入口脚本
|
||||
│ ├── eslint.config.js # ESLint 配置
|
||||
│ ├── index.html # HTML 模板
|
||||
│ ├── nginx.conf # Nginx 配置
|
||||
│ ├── package.json # 前端依赖
|
||||
│ ├── tsconfig.json # TypeScript 配置
|
||||
│ │ ├── db/ # Drizzle ORM + SQLite
|
||||
│ │ ├── errors/ # 自定义错误类
|
||||
│ │ ├── middleware/ # Express 中间件
|
||||
│ │ │ ├── authMiddleware.ts
|
||||
│ │ │ ├── roleBasedAuthMiddleware.ts
|
||||
│ │ │ ├── roleBasedSettingsMiddleware.ts
|
||||
│ │ │ └── errorHandler.ts
|
||||
│ │ ├── routes/ # 路由定义
|
||||
│ │ │ ├── api.ts # 主 API 路由
|
||||
│ │ │ └── settingsRoutes.ts # 设置相关路由
|
||||
│ │ ├── scripts/ # 工具脚本 (VTT 清理、字幕重扫)
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ │ ├── cloudStorage/ # 云存储相关与缓存
|
||||
│ │ │ ├── continuousDownload/ # 订阅任务引擎
|
||||
│ │ │ ├── downloaders/ # 平台下载器 (yt-dlp、Bilibili、MissAV)
|
||||
│ │ │ ├── storageService/ # 模块化存储服务
|
||||
│ │ │ └── *.ts # 其他服务 (auth、metadata 等)
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ ├── server.ts # 服务器入口
|
||||
│ │ └── version.ts # 版本信息
|
||||
│ ├── bgutil-ytdlp-pot-provider/ # PO Token 提供者插件
|
||||
│ ├── data/ # 运行数据 (数据库、hooks、备份)
|
||||
│ ├── drizzle/ # 数据库迁移
|
||||
│ ├── uploads/ # 媒体存储 (videos/images/subtitles/cache)
|
||||
│ ├── scripts/ # 维护脚本 (reset-password、migrate、verify)
|
||||
│ ├── Dockerfile
|
||||
│ ├── drizzle.config.ts
|
||||
│ ├── nodemon.json
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── vitest.config.ts
|
||||
├── frontend/ # React 前端 (Vite + TypeScript)
|
||||
│ ├── src/ # 源代码
|
||||
│ │ ├── __tests__/ # 测试文件
|
||||
│ │ ├── assets/ # 静态资源
|
||||
│ │ ├── components/ # 按功能划分的 UI 组件
|
||||
│ │ ├── contexts/ # React Context 状态
|
||||
│ │ ├── hooks/ # 自定义 hooks (播放器、设置、数据)
|
||||
│ │ ├── pages/ # 路由页面
|
||||
│ │ ├── utils/ # API 客户端、工具、多语言
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── main.tsx
|
||||
│ │ ├── theme.ts
|
||||
│ │ └── version.ts
|
||||
│ ├── public/ # 公共静态资源
|
||||
│ ├── Dockerfile
|
||||
│ ├── entrypoint.sh
|
||||
│ ├── nginx.conf
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── tsconfig.node.json
|
||||
│ └── vite.config.js # Vite 配置
|
||||
├── documents/ # 文档
|
||||
│ ├── en/ # 英文文档
|
||||
│ │ ├── api-endpoints.md
|
||||
│ │ ├── directory-structure.md
|
||||
│ │ ├── docker-guide.md
|
||||
│ │ └── getting-started.md
|
||||
│ └── zh/ # 中文文档
|
||||
│ ├── api-endpoints.md
|
||||
│ ├── directory-structure.md
|
||||
│ ├── docker-guide.md
|
||||
│ └── getting-started.md
|
||||
├── data/ # 根数据目录 (可选)
|
||||
│ └── mytube.db
|
||||
├── build-and-push.sh # Docker 构建和推送脚本
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── CHANGELOG.md # 更新日志
|
||||
├── CODE_OF_CONDUCT.md # 行为准则
|
||||
├── CONTRIBUTING.md # 贡献指南
|
||||
├── LICENSE # MIT 许可证
|
||||
├── README.md # 英文 README
|
||||
├── README-zh.md # 中文 README
|
||||
├── RELEASING.md # 发布流程指南
|
||||
├── SECURITY.md # 安全策略
|
||||
└── package.json # 运行两个应用的根 package.json
|
||||
│ └── vite.config.js
|
||||
├── documents/ # 文档 (EN/ZH)
|
||||
├── docker-compose.yml # 标准部署 Compose
|
||||
├── docker-compose.host-network.yml # OpenWrt/iStoreOS 的 host 网络 Compose
|
||||
├── README.md
|
||||
├── README-zh.md
|
||||
└── package.json # 根目录脚本
|
||||
```
|
||||
|
||||
## 架构概述
|
||||
|
||||
### 后端架构
|
||||
|
||||
后端遵循**分层架构**模式:
|
||||
后端遵循 **分层架构**:
|
||||
|
||||
1. **路由层** (`routes/`): 定义 API 端点并将其映射到控制器
|
||||
2. **控制器层** (`controllers/`): 处理 HTTP 请求/响应并委托给服务
|
||||
3. **服务层** (`services/`): 包含业务逻辑
|
||||
- **下载器**: 用于平台特定下载器的抽象基类模式
|
||||
- **存储服务**: 拆分为专注模块的模块化服务
|
||||
- **支持服务**: 下载管理、订阅、元数据等
|
||||
4. **数据库层** (`db/`): 使用 Drizzle ORM 和 SQLite 进行数据持久化
|
||||
5. **工具层** (`utils/`): 共享工具函数
|
||||
1. **Routes** (`routes/`): 定义 API 端点并映射到控制器
|
||||
2. **Controllers** (`controllers/`): 处理 HTTP 请求/响应
|
||||
3. **Services** (`services/`): 业务逻辑(下载器、存储、云存储、订阅等)
|
||||
4. **Database** (`db/`): Drizzle ORM + SQLite
|
||||
5. **Utils** (`utils/`): 通用工具与基础设施
|
||||
|
||||
### 前端架构
|
||||
|
||||
前端遵循**基于组件的架构**:
|
||||
前端遵循 **组件化架构**:
|
||||
|
||||
1. **页面** (`pages/`): 顶级路由组件
|
||||
2. **组件** (`components/`): 按功能组织的可重用 UI 组件
|
||||
3. **上下文** (`contexts/`): 用于全局状态管理的 React Context API
|
||||
4. **Hooks** (`hooks/`): 用于可重用逻辑的自定义 React hooks
|
||||
5. **工具** (`utils/`): 辅助函数和国际化
|
||||
1. **Pages** (`pages/`): 顶级路由组件
|
||||
2. **Components** (`components/`): 以功能划分的 UI 组件
|
||||
3. **Contexts** (`contexts/`): React Context 全局状态
|
||||
4. **Hooks** (`hooks/`): 可复用逻辑和数据访问
|
||||
5. **Utils** (`utils/`): API、格式化和多语言辅助
|
||||
|
||||
### 数据库模式
|
||||
|
||||
应用程序使用 **SQLite** 和 **Drizzle ORM** 进行数据持久化。关键表包括:
|
||||
关键表包括:
|
||||
|
||||
- `videos`: 视频元数据和文件路径
|
||||
- `collections`: 视频收藏夹/播放列表
|
||||
- `collection_videos`: 视频和收藏夹之间的多对多关系
|
||||
- `collection_videos`: 视频与收藏夹的多对多关系
|
||||
- `subscriptions`: 频道/创作者订阅
|
||||
- `downloads`: 活动下载队列
|
||||
- `download_history`: 完成的下载历史
|
||||
- `video_downloads`: 跟踪已下载的视频以防止重复
|
||||
- `settings`: 应用程序配置
|
||||
- `video_downloads`: 跟踪已下载视频以防止重复
|
||||
- `settings`: 应用配置
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MyTube Docker 部署指南
|
||||
|
||||
本指南提供了使用 Docker 和 Docker Compose 部署 [MyTube](https://github.com/franklioxygen/MyTube "null") 的详细步骤。此设置适用于标准环境(Linux, macOS, Windows),并针对通用用途修改了原本专用于 QNAP 的配置。
|
||||
本指南提供了使用 Docker 和 Docker Compose 部署 [MyTube](https://github.com/franklioxygen/MyTube "null") 的详细步骤。仓库中包含面向 QNAP 的 Compose 文件,请按需调整卷路径,或直接使用下面的通用示例。
|
||||
|
||||
> [!NOTE] > **多架构支持:** 官方镜像支持 **amd64** (x86_64) 和 **arm64** (Apple Silicon, Raspberry Pi 等) 架构。Docker 会自动为您的系统拉取正确的镜像。
|
||||
|
||||
@@ -21,7 +21,7 @@ cd mytube-deploy
|
||||
|
||||
在文件夹中创建一个名为 `docker-compose.yml` 的文件,并粘贴以下内容。
|
||||
|
||||
**注意:** 此版本使用标准的相对路径(`./data`, `./uploads`),而不是原始仓库中特定于 QNAP 的路径。
|
||||
**注意:** 此版本使用标准相对路径(`./data`, `./uploads`)。若使用仓库内的 `docker-compose.yml`,请先调整卷路径。
|
||||
|
||||
```yaml
|
||||
version: "3.8"
|
||||
@@ -38,8 +38,6 @@ services:
|
||||
- mytube-network
|
||||
environment:
|
||||
- PORT=5551
|
||||
# 可选:如果需要,在容器内设置自定义上传目录
|
||||
# - VIDEO_DIR=/app/uploads/videos
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./data:/app/data
|
||||
@@ -204,3 +202,9 @@ docker-compose up -d
|
||||
3. 删除(或注释掉)两个服务的 `ports` 和 `networks` 部分。
|
||||
4. 在 `frontend` 环境变量中设置 `NGINX_BACKEND_URL=http://localhost:5551`。
|
||||
5. 重启容器:`docker-compose up -d`
|
||||
|
||||
或者直接使用仓库提供的 `docker-compose.host-network.yml`:
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose.host-network.yml up -d
|
||||
```
|
||||
|
||||
@@ -52,23 +52,21 @@ pipx inject yt-dlp bgutil-ytdlp-pot-provider
|
||||
|
||||
```env
|
||||
PORT=5551
|
||||
UPLOAD_DIR=uploads
|
||||
VIDEO_DIR=uploads/videos
|
||||
IMAGE_DIR=uploads/images
|
||||
SUBTITLES_DIR=uploads/subtitles
|
||||
DATA_DIR=data
|
||||
MAX_FILE_SIZE=500000000
|
||||
```
|
||||
|
||||
默认数据与上传路径位于 `backend/data` 和 `backend/uploads`(相对于后端工作目录)。
|
||||
|
||||
#### 前端配置
|
||||
|
||||
在 `frontend/` 目录中创建 `.env` 文件:
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5551/api
|
||||
VITE_BACKEND_URL=http://localhost:5551
|
||||
VITE_API_URL=/api
|
||||
VITE_BACKEND_URL=
|
||||
```
|
||||
|
||||
项目提供 `backend/.env.example`,请复制为 `backend/.env` 并按需调整。前端已提供 `frontend/.env`,如需覆盖默认值请使用 `frontend/.env.local`。
|
||||
|
||||
### 4. 数据库设置
|
||||
|
||||
应用程序使用 **SQLite** 和 **Drizzle ORM**。数据库将在首次启动时自动创建和迁移:
|
||||
@@ -92,7 +90,7 @@ npm run dev
|
||||
- **前端**: http://localhost:5556 (带热重载的 Vite 开发服务器)
|
||||
- **后端 API**: http://localhost:5551 (带 nodemon 的 Express 服务器)
|
||||
|
||||
### 生产模式
|
||||
### 生产模式 (本地)
|
||||
|
||||
构建并以生产模式启动:
|
||||
|
||||
@@ -100,10 +98,19 @@ npm run dev
|
||||
# 构建前端
|
||||
npm run build
|
||||
|
||||
# 启动两个服务
|
||||
# 启动后端
|
||||
cd backend
|
||||
npm run start
|
||||
|
||||
# 在另一个终端预览前端构建
|
||||
cd frontend
|
||||
npm run preview
|
||||
```
|
||||
|
||||
生产环境建议参考 [Docker 部署指南](docker-guide.md)。
|
||||
|
||||
根目录的 `npm run start` 为便捷命令,会同时运行后端启动脚本和前端开发服务器。
|
||||
|
||||
### 单独运行服务
|
||||
|
||||
您也可以单独运行服务:
|
||||
@@ -126,9 +133,12 @@ npm run preview # 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run dev # 以开发模式启动前端和后端
|
||||
npm run start # 以生产模式启动前端和后端
|
||||
npm run start # 启动后端 + 前端开发服务器 (便捷)
|
||||
npm run build # 为生产环境构建前端
|
||||
npm run install:all # 安装根目录、前端和后端的依赖
|
||||
npm run test # 运行前后端测试
|
||||
npm run test:frontend # 运行前端测试
|
||||
npm run test:backend # 运行后端测试
|
||||
```
|
||||
|
||||
后端特定脚本 (从 `backend/` 目录):
|
||||
@@ -140,6 +150,7 @@ npm run build # 将 TypeScript 编译为 JavaScript
|
||||
npm run test # 使用 Vitest 运行测试
|
||||
npm run test:coverage # 运行测试并生成覆盖率报告
|
||||
npm run generate # 使用 Drizzle Kit 生成数据库迁移
|
||||
npm run reset-password # 使用脚本重置管理员密码
|
||||
```
|
||||
|
||||
前端特定脚本 (从 `frontend/` 目录):
|
||||
@@ -150,16 +161,18 @@ npm run build # 为生产环境构建
|
||||
npm run preview # 预览生产构建
|
||||
npm run lint # 运行 ESLint
|
||||
npm run test # 使用 Vitest 运行测试
|
||||
npm run test:coverage # 运行测试并生成覆盖率报告
|
||||
```
|
||||
|
||||
## 首次设置
|
||||
|
||||
1. **访问应用**: 在浏览器中打开 http://localhost:5556
|
||||
|
||||
2. **设置密码保护** (可选):
|
||||
2. **设置登录保护** (可选):
|
||||
|
||||
- 转到设置 → 安全
|
||||
- 启用密码保护并设置密码
|
||||
- 启用登录并设置管理员密码
|
||||
- 可选:注册通行密钥 (WebAuthn)
|
||||
|
||||
3. **配置下载设置**:
|
||||
|
||||
@@ -183,12 +196,11 @@ npm run test # 使用 Vitest 运行测试
|
||||
- 测试连接以验证设置
|
||||
- 注意:启用后,视频将在下载后自动上传到云存储,本地文件将被删除
|
||||
|
||||
6. **配置访客模式** (可选):
|
||||
6. **配置访客用户** (可选):
|
||||
|
||||
- 转到设置 → 常规设置
|
||||
- 启用"访客模式(只读)"以允许查看视频但无法进行修改
|
||||
- 启用后,所有写入操作(下载、删除、编辑)将被阻止
|
||||
- 要禁用访客模式,您需要输入网站密码
|
||||
- 转到设置 → 安全
|
||||
- 启用“访客用户”以获得只读访问
|
||||
- 为访客角色设置登录密码
|
||||
|
||||
7. **开始下载**:
|
||||
- 在下载输入框中输入视频 URL
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
# 任务钩子使用指南 (Task Hooks Guide)
|
||||
|
||||
MyTube允许您在下载任务生命周期的不同阶段执行自定义Shell脚本。此功能对于与其他系统集成、对下载的文件进行后处理或发送通知非常有用。
|
||||
MyTube 允许您在下载任务的不同阶段执行自定义 Shell 脚本,用于后处理、通知或外部集成。
|
||||
|
||||
## 可用钩子 (Available Hooks)
|
||||
|
||||
您可以为以下事件配置命令:
|
||||
|
||||
| 钩子名称 | 触发时机 | 描述 |
|
||||
| :--- | :--- | :--- |
|
||||
| **任务开始前** (`task_before_start`) | 下载开始前 | 在下载过程开始之前立即执行。适用于设置或验证。 |
|
||||
| **任务成功** (`task_success`) | 下载成功后 | 在文件成功下载/合并后,但在上传到云存储(如果启用)或删除之前执行。这是文件处理的理想位置。 |
|
||||
| **任务失败** (`task_fail`) | 任务失败时 | 如果下载因任何原因失败,则执行此钩子。 |
|
||||
| **任务取消** (`task_cancel`) | 任务取消时 | 当用户手动取消正在运行的任务时执行。 |
|
||||
| **任务开始前** (`task_before_start`) | 下载开始前 | 在下载开始前立即执行。 |
|
||||
| **任务成功** (`task_success`) | 下载成功后 | 下载/合并完成后执行,且在云存储上传(若启用)或删除之前执行。 |
|
||||
| **任务失败** (`task_fail`) | 任务失败时 | 下载失败时执行。 |
|
||||
| **任务取消** (`task_cancel`) | 任务取消时 | 用户取消正在运行的任务时执行。 |
|
||||
|
||||
默认脚本目录为 `backend/data/hooks`。
|
||||
|
||||
## 环境变量 (Environment Variables)
|
||||
|
||||
当钩子命令执行时,以下环境变量将被注入到Shell环境,提供有关任务的上下文:
|
||||
|
||||
| 变量 | 描述 | 示例 |
|
||||
| :--- | :--- | :--- |
|
||||
| `MYTUBE_TASK_ID` | 任务的唯一标识符 | `335e98f0-15cb-46a4-846d-9d4351368923` |
|
||||
| `MYTUBE_TASK_TITLE` | 视频/任务的标题 | `Awesome Video Title` |
|
||||
| `MYTUBE_SOURCE_URL` | 视频的原始URL | `https://www.youtube.com/watch?v=...` |
|
||||
| `MYTUBE_TASK_STATUS` | 任务的当前状态 | `success`, `fail`, `cancelled` |
|
||||
| `MYTUBE_VIDEO_PATH` | 下载视频文件的绝对路径 | `/app/downloads/video.mp4` |
|
||||
| `MYTUBE_THUMBNAIL_PATH` | 缩略图文件的绝对路径 | `/app/downloads/video.jpg` |
|
||||
| `MYTUBE_ERROR` | 错误消息(仅限 `task_fail`) | `Network timeout` |
|
||||
| `MYTUBE_TASK_ID` | 任务唯一标识 | `335e98f0-15cb-46a4-846d-9d4351368923` |
|
||||
| `MYTUBE_TASK_TITLE` | 视频/任务标题 | `Awesome Video Title` |
|
||||
| `MYTUBE_SOURCE_URL` | 视频原始 URL | `https://www.youtube.com/watch?v=...` |
|
||||
| `MYTUBE_TASK_STATUS` | 当前状态 | `start`, `success`, `fail`, `cancel` |
|
||||
| `MYTUBE_VIDEO_PATH` | 视频文件绝对路径 | `/app/uploads/videos/video.mp4` |
|
||||
| `MYTUBE_THUMBNAIL_PATH` | 缩略图绝对路径 | `/app/uploads/images/video.jpg` |
|
||||
| `MYTUBE_ERROR` | 错误消息(仅 `task_fail`) | `Network timeout` |
|
||||
|
||||
## 配置 (Configuration)
|
||||
|
||||
您可以在网页界面中配置钩子:
|
||||
1. 转到 **设置 (Settings)**。
|
||||
2. 向下滚动到 **高级设置 (Advanced Settings)**。
|
||||
3. 找到 **任务钩子 (Task Hooks)** 部分。
|
||||
4. 为特定事件上传您的 `.sh` 脚本。
|
||||
5. 您可以根据需要删除或重新上传脚本。
|
||||
1. 进入 **设置 (Settings)**。
|
||||
2. 打开 **高级设置 (Advanced Settings)**。
|
||||
3. 找到 **任务钩子 (Task Hooks)**。
|
||||
4. 上传对应事件的 `.sh` 脚本。
|
||||
5. 需要时可删除或重新上传。
|
||||
|
||||
钩子脚本使用 `bash` 执行,上传后会自动赋予可执行权限。
|
||||
|
||||
## 查看日志 (Viewing Logs)
|
||||
|
||||
脚本的任何输出 (stdout/stderr) 都将被捕获并记录到 MyTube 服务器控制台。
|
||||
脚本输出 (stdout/stderr) 会被后端捕获并记录。
|
||||
- 标准输出 (`echo "..."`) 记录为 `INFO`。
|
||||
- 标准错误 (`>&2 echo "..."`) 记录为 `WARN`。
|
||||
|
||||
@@ -79,7 +78,7 @@ python3 /path/to/process_video.py "$MYTUBE_VIDEO_PATH"
|
||||
## 安全警告 (Security Warning)
|
||||
|
||||
> [!WARNING]
|
||||
> 钩子命令以与MyTube后端服务器相同的权限执行。
|
||||
> 钩子命令以与 MyTube 后端相同的权限执行。
|
||||
> - 使用修改或删除文件的命令时请务必小心。
|
||||
> - 请勿复制粘贴来自不可信来源的脚本。
|
||||
> - 确保您的脚本能够优雅地处理错误。
|
||||
> - 确保脚本能正确处理错误。
|
||||
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.34",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.13.2",
|
||||
@@ -1918,6 +1919,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.34",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,6 +16,7 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@@ -11,7 +11,6 @@ import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { SnackbarProvider } from './contexts/SnackbarContext';
|
||||
import { ThemeContextProvider } from './contexts/ThemeContext';
|
||||
import { VideoProvider, useVideo } from './contexts/VideoContext';
|
||||
import { VisitorModeProvider } from './contexts/VisitorModeContext';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import DownloadPage from './pages/DownloadPage';
|
||||
@@ -147,17 +146,15 @@ function App() {
|
||||
<ThemeContextProvider>
|
||||
<LanguageProvider>
|
||||
<SnackbarProvider>
|
||||
<VisitorModeProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
</VisitorModeProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
</SnackbarProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeContextProvider>
|
||||
|
||||
@@ -41,12 +41,11 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[8],
|
||||
},
|
||||
border: `1px solid ${theme.palette.secondary.main}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={handleClick} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
|
||||
@@ -76,7 +75,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
|
||||
|
||||
<Chip
|
||||
icon={<Folder />}
|
||||
label={`${collection.videos.length} videos`}
|
||||
label={collection.videos.length}
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8 }}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Brightness4, Brightness7, Download, Settings } from '@mui/icons-materia
|
||||
import { Badge, Box, IconButton, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useThemeContext } from '../../contexts/ThemeContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import DownloadsMenu from './DownloadsMenu';
|
||||
import ManageMenu from './ManageMenu';
|
||||
import { DownloadInfo } from './types';
|
||||
@@ -32,14 +32,15 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
}) => {
|
||||
const { mode: currentThemeMode, toggleTheme } = useThemeContext();
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={onDownloadsClick}>
|
||||
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Help, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Help, Logout, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import {
|
||||
alpha,
|
||||
Divider,
|
||||
Fade,
|
||||
Menu,
|
||||
MenuItem,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface ManageMenuProps {
|
||||
@@ -21,8 +25,33 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const { logout } = useAuth();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Check if login is enabled
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const loginEnabled = settingsData?.loginEnabled || false;
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -68,6 +97,12 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
|
||||
<MenuItem onClick={() => { onClose(); navigate('/instruction'); }}>
|
||||
<Help sx={{ mr: 2 }} /> {t('instruction')}
|
||||
</MenuItem>
|
||||
{loginEnabled && <Divider />}
|
||||
{loginEnabled && (
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 2 }} /> {t('logout')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Box, Button, Collapse, Stack } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Logout, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Box, Button, Collapse, Divider, Stack } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { Collection, Video } from '../../types';
|
||||
import AuthorsList from '../AuthorsList';
|
||||
import Collections from '../Collections';
|
||||
@@ -42,17 +44,43 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
videos = [],
|
||||
availableTags = [],
|
||||
selectedTags = [],
|
||||
onTagToggle
|
||||
onTagToggle = () => { }
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { logout, userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const navigate = useNavigate();
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Check if login is enabled
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const loginEnabled = settingsData?.loginEnabled || false;
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse in={open} sx={{ width: '100%' }}>
|
||||
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
{/* Row 1: Search Input */}
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Box>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
@@ -91,6 +119,22 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Logout Button */}
|
||||
{loginEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={handleLogout}
|
||||
startIcon={<Logout />}
|
||||
>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile Navigation Items */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Collections
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { FormEvent } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
|
||||
interface SearchInputProps {
|
||||
videoUrl: string;
|
||||
@@ -36,7 +36,8 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onSubmit
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
@@ -58,10 +59,10 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder={visitorMode ? t('visitorModeReadOnly') || 'Visitor mode: Read-only' : t('enterUrlOrSearchTerm')}
|
||||
placeholder={isVisitor ? t('visitorModeReadOnly') || 'Visitor mode: Read-only' : t('enterUrlOrSearchTerm')}
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
size="small"
|
||||
@@ -79,7 +80,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onClick={handlePaste}
|
||||
edge="start"
|
||||
size="small"
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
sx={{ ml: 0 }}
|
||||
>
|
||||
<ContentPaste />
|
||||
@@ -98,7 +99,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onClick={handleClear}
|
||||
edge="end"
|
||||
size="small"
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
sx={{ mr: 0.5 }}
|
||||
>
|
||||
<Clear />
|
||||
@@ -107,7 +108,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
|
||||
@@ -9,8 +9,8 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
}));
|
||||
|
||||
const mockVisitorMode = false;
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: mockVisitorMode }),
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ userRole: mockVisitorMode ? 'visitor' : 'admin' }),
|
||||
}));
|
||||
|
||||
// Mock useMediaQuery
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useThemeContext } from '../../contexts/ThemeContext';
|
||||
import { useVideo } from '../../contexts/VideoContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ActionButtons from './ActionButtons';
|
||||
import Logo from './Logo';
|
||||
import MobileMenu from './MobileMenu';
|
||||
@@ -49,7 +49,8 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const { mode: themeMode } = useThemeContext();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||
|
||||
const isSettingsPage = location.pathname.startsWith('/settings');
|
||||
@@ -61,7 +62,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
// Check for active subscriptions and tasks
|
||||
useEffect(() => {
|
||||
if (visitorMode) {
|
||||
if (isVisitor) {
|
||||
setHasActiveSubscriptions(false);
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +99,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [visitorMode]);
|
||||
}, [isVisitor]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch settings to get website name and infinite scroll setting
|
||||
@@ -316,7 +317,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{/* Desktop Layout */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
@@ -330,7 +331,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: visitorMode ? 'auto' : 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: isVisitor ? 'auto' : 2 }}>
|
||||
<ActionButtons
|
||||
activeDownloads={activeDownloads}
|
||||
queuedDownloads={queuedDownloads}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Collection } from '../../types';
|
||||
|
||||
interface CollectionsTableProps {
|
||||
@@ -40,7 +40,8 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
getCollectionSize
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
|
||||
return (
|
||||
@@ -59,7 +60,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
<TableCell>{t('videos')}</TableCell>
|
||||
<TableCell>{t('size')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
{!isVisitor && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -71,7 +72,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
<TableCell>{collection.videos.length} videos</TableCell>
|
||||
<TableCell>{getCollectionSize(collection.videos)}</TableCell>
|
||||
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteCollection')} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
|
||||
import { Video } from '../../types';
|
||||
import { formatDuration, formatSize } from '../../utils/formatUtils';
|
||||
@@ -96,7 +96,8 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
onUpdateVideo
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
|
||||
// Local editing state
|
||||
@@ -184,7 +185,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
{t('size')}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
{!isVisitor && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -195,7 +196,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
<Link to={`/video/${video.id}`} style={{ display: 'block', width: '100%', height: '100%' }}>
|
||||
<ThumbnailImage video={video} />
|
||||
</Link>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -255,7 +256,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditClick(video)}
|
||||
@@ -296,7 +297,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{formatSize(video.fileSize)}</TableCell>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteVideo')} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
|
||||
@@ -8,14 +8,10 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: false }),
|
||||
}));
|
||||
|
||||
// We need to support mocking the return value of useVisitorMode for specific tests
|
||||
const mockUseVisitorMode = vi.fn(() => ({ visitorMode: false }));
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => mockUseVisitorMode(),
|
||||
// We need to support mocking the return value of useAuth for specific tests
|
||||
const mockUseAuth = vi.fn(() => ({ userRole: 'admin' }));
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -59,11 +55,11 @@ describe('CollectionsTable', () => {
|
||||
});
|
||||
|
||||
it('should not show actions column in visitor mode', () => {
|
||||
mockUseVisitorMode.mockReturnValue({ visitorMode: true });
|
||||
mockUseAuth.mockReturnValue({ userRole: 'visitor' });
|
||||
render(<CollectionsTable {...defaultProps} />);
|
||||
expect(screen.queryByText('actions')).not.toBeInTheDocument();
|
||||
// Reset mock
|
||||
mockUseVisitorMode.mockReturnValue({ visitorMode: false });
|
||||
mockUseAuth.mockReturnValue({ userRole: 'admin' });
|
||||
});
|
||||
|
||||
it('should render pagination if totalPages > 1', () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: false }),
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ userRole: 'admin' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useCloudStorageUrl', () => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, FormControl, InputLabel, MenuItem, Select, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface BasicSettingsProps {
|
||||
@@ -10,6 +11,8 @@ interface BasicSettingsProps {
|
||||
|
||||
const BasicSettings: React.FC<BasicSettingsProps> = ({ language, websiteName, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -36,21 +39,23 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ language, websiteName, on
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('websiteName')}
|
||||
value={websiteName || ''}
|
||||
onChange={(e) => onChange('websiteName', e.target.value)}
|
||||
placeholder="MyTube"
|
||||
helperText={t('websiteNameHelper', {
|
||||
current: (websiteName || '').length,
|
||||
max: 15,
|
||||
default: 'MyTube'
|
||||
})}
|
||||
slotProps={{ htmlInput: { maxLength: 15 } }}
|
||||
/>
|
||||
{!isVisitor && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('websiteName')}
|
||||
value={websiteName || ''}
|
||||
onChange={(e) => onChange('websiteName', e.target.value)}
|
||||
placeholder="MyTube"
|
||||
helperText={t('websiteNameHelper', {
|
||||
current: (websiteName || '').length,
|
||||
max: 15,
|
||||
default: 'MyTube'
|
||||
})}
|
||||
slotProps={{ htmlInput: { maxLength: 15 } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, Box, CircularProgress, FormControlLabel, Switch, TextField, Tooltip, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../contexts/SnackbarContext';
|
||||
import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
|
||||
@@ -7,13 +8,14 @@ import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
|
||||
interface CloudflareSettingsProps {
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
visitorMode?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token, visitorMode, onChange }) => {
|
||||
const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
@@ -63,7 +65,7 @@ const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token,
|
||||
<Switch
|
||||
checked={enabled ?? false}
|
||||
onChange={(e) => onChange('cloudflaredTunnelEnabled', e.target.checked)}
|
||||
disabled={visitorMode ?? false}
|
||||
disabled={isVisitor}
|
||||
/>
|
||||
}
|
||||
label={t('enableCloudflaredTunnel')}
|
||||
@@ -77,7 +79,7 @@ const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token,
|
||||
value={token || ''}
|
||||
onChange={(e) => onChange('cloudflaredToken', e.target.value)}
|
||||
margin="normal"
|
||||
disabled={visitorMode ?? false}
|
||||
disabled={isVisitor}
|
||||
helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Settings } from '../../types';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
interface HookSettingsProps {
|
||||
settings: Settings;
|
||||
@@ -17,6 +18,11 @@ const API_URL = import.meta.env.VITE_API_URL;
|
||||
const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
const { t } = useLanguage();
|
||||
const [deleteHookName, setDeleteHookName] = useState<string | null>(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | undefined>(undefined);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [pendingUpload, setPendingUpload] = useState<{ hookName: string; file: File } | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const { data: hookStatus, refetch: refetchHooks, isLoading } = useQuery({
|
||||
queryKey: ['hookStatus'],
|
||||
@@ -36,6 +42,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchHooks();
|
||||
setPendingUpload(null);
|
||||
setUploadError(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Upload failed:', error);
|
||||
const message = error.response?.data?.message || error.message;
|
||||
// Try to match risk command error
|
||||
// Backend sends: "Risk command detected: {command}. Upload rejected."
|
||||
const riskMatch = message?.match(/Risk command detected: (.*)\. Upload rejected\./);
|
||||
if (riskMatch && riskMatch[1]) {
|
||||
setUploadError(t('riskCommandDetected', { command: riskMatch[1] }));
|
||||
} else {
|
||||
setUploadError(message || t('uploadFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,10 +78,35 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadMutation.mutate({ hookName, file });
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
|
||||
setPendingUpload({ hookName, file });
|
||||
setPasswordError(undefined);
|
||||
setUploadError(null);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifying(true);
|
||||
setPasswordError(undefined);
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
setShowPasswordModal(false);
|
||||
if (pendingUpload) {
|
||||
uploadMutation.mutate(pendingUpload);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response?.status === 429) {
|
||||
const waitTime = error.response.data.waitTime;
|
||||
setPasswordError(t('tooManyAttempts') + ` Try again in ${Math.ceil(waitTime / 1000)}s`);
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword'));
|
||||
}
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (hookName: string) => {
|
||||
@@ -109,6 +154,12 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
{t('taskHooksWarning')}
|
||||
</Alert>
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setUploadError(null)}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
@@ -185,6 +236,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
cancelText={t('cancel') || 'Cancel'}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingUpload(null);
|
||||
setPasswordError(undefined);
|
||||
}}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('enterPassword')}
|
||||
message={t('enterPasswordToUploadHook') || 'Please enter your password to upload this hook script.'}
|
||||
error={passwordError}
|
||||
isLoading={isVerifying}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Box, FormControlLabel, Switch, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FingerprintIcon from '@mui/icons-material/Fingerprint';
|
||||
import { Box, Button, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Settings } from '../../types';
|
||||
import { getWebAuthnErrorTranslationKey } from '../../utils/translations';
|
||||
import AlertModal from '../AlertModal';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface SecuritySettingsProps {
|
||||
settings: Settings;
|
||||
@@ -10,6 +20,133 @@ interface SecuritySettingsProps {
|
||||
|
||||
const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
|
||||
const showAlert = (title: string, message: string) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertOpen(true);
|
||||
};
|
||||
|
||||
// Check if passkeys exist
|
||||
const { data: passkeysData, refetch: refetchPasskeys } = useQuery({
|
||||
queryKey: ['passkeys-exists'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings/passkeys/exists`);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const passkeysExist = passkeysData?.exists || false;
|
||||
|
||||
// If passkeys don't exist, automatically enable and lock password login
|
||||
useEffect(() => {
|
||||
if (!passkeysExist && settings.loginEnabled && settings.passwordLoginAllowed === false) {
|
||||
onChange('passwordLoginAllowed', true);
|
||||
}
|
||||
}, [passkeysExist, settings.loginEnabled, settings.passwordLoginAllowed, onChange]);
|
||||
|
||||
// Create passkey mutation
|
||||
const createPasskeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
|
||||
// Step 1: Get registration options
|
||||
const optionsResponse = await axios.post(`${API_URL}/settings/passkeys/register`, {
|
||||
userName: 'MyTube User',
|
||||
});
|
||||
const { options, challenge } = optionsResponse.data;
|
||||
|
||||
// Step 2: Start registration with browser
|
||||
const attestationResponse = await startRegistration(options);
|
||||
|
||||
// Step 3: Verify registration
|
||||
const verifyResponse = await axios.post(`${API_URL}/settings/passkeys/register/verify`, {
|
||||
body: attestationResponse,
|
||||
challenge,
|
||||
});
|
||||
|
||||
if (!verifyResponse.data.success) {
|
||||
throw new Error('Passkey registration failed');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchPasskeys();
|
||||
refetchPasskeys();
|
||||
showAlert(t('success'), t('passkeyCreated') || 'Passkey created successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Error creating passkey:', error);
|
||||
// Extract error message from axios response or error object
|
||||
let errorMessage = t('passkeyCreationFailed') || 'Failed to create passkey. Please try again.';
|
||||
|
||||
if (error?.response?.data?.error) {
|
||||
// Backend error message
|
||||
errorMessage = error.response.data.error;
|
||||
} else if (error?.response?.data?.message) {
|
||||
errorMessage = error.response.data.message;
|
||||
} else if (error?.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// Check if this is a WebAuthn error that can be translated
|
||||
const translationKey = getWebAuthnErrorTranslationKey(errorMessage);
|
||||
if (translationKey) {
|
||||
errorMessage = t(translationKey) || errorMessage;
|
||||
}
|
||||
|
||||
showAlert(t('error'), errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
// Remove passkeys mutation
|
||||
const removePasskeysMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios.delete(`${API_URL}/settings/passkeys`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchPasskeys();
|
||||
setShowRemoveModal(false);
|
||||
showAlert(t('success'), t('passkeysRemoved') || 'All passkeys have been removed');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Error removing passkeys:', error);
|
||||
showAlert(t('error'), t('passkeysRemoveFailed') || 'Failed to remove passkeys. Please try again.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreatePasskey = () => {
|
||||
// Check if we're in a secure context (HTTPS or localhost)
|
||||
// This is the most important check - WebAuthn requires secure context
|
||||
if (!window.isSecureContext) {
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
||||
if (!isLocalhost) {
|
||||
showAlert(t('error'), t('passkeyRequiresHttps') || 'WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if WebAuthn is supported
|
||||
// Check multiple ways to detect WebAuthn support
|
||||
const hasWebAuthn =
|
||||
typeof window.PublicKeyCredential !== 'undefined' ||
|
||||
(typeof navigator !== 'undefined' && 'credentials' in navigator && 'create' in navigator.credentials);
|
||||
|
||||
if (!hasWebAuthn) {
|
||||
showAlert(t('error'), t('passkeyWebAuthnNotSupported') || 'WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.');
|
||||
return;
|
||||
}
|
||||
|
||||
createPasskeyMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRemovePasskeys = () => {
|
||||
removePasskeysMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -24,21 +161,147 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
|
||||
/>
|
||||
|
||||
{settings.loginEnabled && (
|
||||
<Box sx={{ mt: 2, maxWidth: 400 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('password')}
|
||||
type="password"
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => onChange('password', e.target.value)}
|
||||
helperText={
|
||||
settings.isPasswordSet
|
||||
? t('passwordHelper')
|
||||
: t('passwordSetHelper')
|
||||
<Box sx={{ mt: 2 }}>
|
||||
|
||||
{settings.passwordLoginAllowed !== false && (
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ mb: 2, maxWidth: 400 }}
|
||||
label={t('password')}
|
||||
type="password"
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => onChange('password', e.target.value)}
|
||||
helperText={
|
||||
settings.isPasswordSet
|
||||
? t('passwordHelper')
|
||||
: t('passwordSetHelper')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!passkeysExist ? true : (settings.passwordLoginAllowed !== false)}
|
||||
onChange={(e) => onChange('passwordLoginAllowed', e.target.checked)}
|
||||
disabled={!settings.loginEnabled || !passkeysExist}
|
||||
/>
|
||||
}
|
||||
label={t('allowPasswordLogin') || 'Allow Password Login'}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('allowPasswordLoginHelper') || 'When disabled, password login is not available. You must have at least one passkey to disable password login.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.allowResetPassword !== false}
|
||||
onChange={(e) => onChange('allowResetPassword', e.target.checked)}
|
||||
disabled={!settings.loginEnabled}
|
||||
/>
|
||||
}
|
||||
label={t('allowResetPassword') || 'Allow Reset Password'}
|
||||
/>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('allowResetPasswordHelper') || 'When disabled, the reset password button will not be shown on the login page and the reset password API will be blocked.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, maxWidth: 400 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FingerprintIcon />}
|
||||
onClick={handleCreatePasskey}
|
||||
disabled={!settings.loginEnabled || createPasskeyMutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
{createPasskeyMutation.isPending
|
||||
? (t('creatingPasskey') || 'Creating...')
|
||||
: (t('createPasskey') || 'Create Passkey')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => setShowRemoveModal(true)}
|
||||
disabled={!settings.loginEnabled || !passkeysExist || removePasskeysMutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
{t('removePasskeys') || 'Remove All Passkeys'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
{t('visitorUser') || 'Visitor User'}
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.visitorUserEnabled !== false}
|
||||
onChange={(e) => onChange('visitorUserEnabled', e.target.checked)}
|
||||
disabled={!settings.loginEnabled}
|
||||
/>
|
||||
}
|
||||
label={t('enableVisitorUser') || 'Enable Visitor User'}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{settings.visitorUserEnabled !== false && (
|
||||
<>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{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>
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ mb: 2, maxWidth: 400 }}
|
||||
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.')
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showRemoveModal}
|
||||
onClose={() => setShowRemoveModal(false)}
|
||||
onConfirm={handleRemovePasskeys}
|
||||
title={t('removePasskeysTitle') || 'Remove All Passkeys'}
|
||||
message={t('removePasskeysMessage') || 'Are you sure you want to remove all passkeys? This action cannot be undone.'}
|
||||
confirmText={t('remove') || 'Remove'}
|
||||
cancelText={t('cancel') || 'Cancel'}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<AlertModal
|
||||
open={alertOpen}
|
||||
onClose={() => setAlertOpen(false)}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface VisitorModeSettingsProps {
|
||||
visitorMode?: boolean;
|
||||
savedVisitorMode?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const VisitorModeSettings: React.FC<VisitorModeSettingsProps> = ({ visitorMode, savedVisitorMode: _savedVisitorMode, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isVerifyingPassword, setIsVerifyingPassword] = useState(false);
|
||||
const [pendingVisitorMode, setPendingVisitorMode] = useState<boolean | null>(null);
|
||||
const [remainingWaitTime, setRemainingWaitTime] = useState(0);
|
||||
const [baseError, setBaseError] = useState('');
|
||||
|
||||
const handleVisitorModeChange = (checked: boolean) => {
|
||||
setPendingVisitorMode(checked);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifyingPassword(true);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
|
||||
// If successful, save the setting immediately
|
||||
if (pendingVisitorMode !== null) {
|
||||
// Save to backend
|
||||
await axios.post(`${API_URL}/settings`, { visitorMode: pendingVisitorMode });
|
||||
|
||||
// Invalidate settings query to ensure global state (VisitorModeContext) updates immediately
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
|
||||
// Update parent state
|
||||
onChange('visitorMode', pendingVisitorMode);
|
||||
}
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 429) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('tooManyAttempts') || 'Too many attempts.');
|
||||
} else if (status === 401) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
if (waitTimeMs > 0) {
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('incorrectPassword') || 'Incorrect password.');
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword') || 'Incorrect password');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('loginFailed') || 'Verification failed');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('networkError' as any) || 'Network error');
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePasswordModal = () => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
};
|
||||
|
||||
// Effect to handle countdown
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (remainingWaitTime > 0) {
|
||||
// Update error message immediately
|
||||
const waitMsg = t('waitTimeMessage')?.replace('{time}', `${remainingWaitTime}s`) || `Please wait ${remainingWaitTime}s.`;
|
||||
setPasswordError(`${baseError} ${waitMsg}`);
|
||||
|
||||
interval = setInterval(() => {
|
||||
setRemainingWaitTime((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Countdown finished
|
||||
setPasswordError(baseError);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [remainingWaitTime, baseError, t]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visitorMode ?? false}
|
||||
onChange={(e) => handleVisitorModeChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('visitorMode') || "Visitor Mode (Read-only)"}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 4.5 }}>
|
||||
{t('visitorModeDescription') || "Read-only mode. Hidden videos will not be visible to visitors."}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={handleClosePasswordModal}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('password' as any) || "Enter Website Password"}
|
||||
message={t('visitorModePasswordPrompt' as any) || "Please enter the website password to change Visitor Mode settings."}
|
||||
error={passwordError}
|
||||
isLoading={isVerifyingPassword}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitorModeSettings;
|
||||
@@ -60,7 +60,12 @@ const DEFAULT_CONFIG = `# yt-dlp Configuration File
|
||||
# Note: -f filters may not work reliably with all video sources
|
||||
# Use -S above for more consistent results
|
||||
|
||||
# Download best quality (default behavior)
|
||||
# Download best quality (Recommended for 4K/8K)
|
||||
# Note: This will likely use VP9/AV1 codecs which are best for high resolution
|
||||
# -S res:2160
|
||||
|
||||
# Download best quality using format selection (Legacy)
|
||||
# Note: This may be limited to 1080p due to MP4 compatibility requirements
|
||||
# -f bestvideo*+bestaudio/best
|
||||
|
||||
# Limit to 1080p maximum using filter
|
||||
|
||||
@@ -14,6 +14,17 @@ vi.mock('../../../contexts/SnackbarContext', () => ({
|
||||
useSnackbar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
loginRequired: false,
|
||||
checkingAuth: false,
|
||||
userRole: 'admin',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useCloudflareStatus', () => ({
|
||||
useCloudflareStatus: vi.fn(),
|
||||
}));
|
||||
@@ -33,7 +44,7 @@ describe('CloudflareSettings', () => {
|
||||
});
|
||||
|
||||
it('should render switch and fields', () => {
|
||||
render(<CloudflareSettings enabled={true} token="test-token" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="test-token" onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText(/enableCloudflaredTunnel/i)).toBeChecked();
|
||||
expect(screen.getByLabelText(/cloudflaredToken/i)).toHaveValue('test-token');
|
||||
@@ -41,7 +52,7 @@ describe('CloudflareSettings', () => {
|
||||
|
||||
it('should update enable state on switch toggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CloudflareSettings enabled={false} token="" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={false} token="" onChange={mockOnChange} />);
|
||||
|
||||
const switchControl = screen.getByRole('switch', { name: /enableCloudflaredTunnel/i });
|
||||
await user.click(switchControl);
|
||||
@@ -51,7 +62,7 @@ describe('CloudflareSettings', () => {
|
||||
|
||||
it('should update token on change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CloudflareSettings enabled={true} token="" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="" onChange={mockOnChange} />);
|
||||
|
||||
const tokenInput = screen.getByLabelText(/cloudflaredToken/i);
|
||||
await user.type(tokenInput, 'new-token');
|
||||
@@ -65,7 +76,7 @@ describe('CloudflareSettings', () => {
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
render(<CloudflareSettings enabled={true} token="test-token" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="test-token" onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByText('running')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://test.trycloudflare.com')).toBeInTheDocument();
|
||||
@@ -89,7 +100,7 @@ describe('CloudflareSettings', () => {
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
render(<CloudflareSettings enabled={true} token="test-token" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="test-token" onChange={mockOnChange} />);
|
||||
|
||||
const urlElement = screen.getByText('https://test.trycloudflare.com');
|
||||
await user.click(urlElement);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import SecuritySettings from '../SecuritySettings';
|
||||
@@ -8,12 +9,30 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const render = (ui: React.ReactElement) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
return rtlRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('SecuritySettings', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const defaultSettings: any = {
|
||||
loginEnabled: false,
|
||||
password: '',
|
||||
isPasswordSet: false,
|
||||
visitorPassword: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -25,15 +44,24 @@ describe('SecuritySettings', () => {
|
||||
|
||||
expect(screen.getByLabelText('enableLogin')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('password')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('visitorPassword')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show password field when enabled', () => {
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText('password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('visitorPassword')).toBeInTheDocument();
|
||||
expect(screen.getByText('passwordSetHelper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show visitor password field when login is enabled', () => {
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText('visitorPassword')).toBeInTheDocument();
|
||||
expect(screen.getByText('visitorPasswordHelper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle switch change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SecuritySettings settings={defaultSettings} onChange={mockOnChange} />);
|
||||
@@ -42,6 +70,8 @@ describe('SecuritySettings', () => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith('loginEnabled', true);
|
||||
});
|
||||
|
||||
// Visitor mode switch has been removed - visitor password is always visible when login is enabled
|
||||
|
||||
it('should handle password change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
@@ -50,4 +80,15 @@ describe('SecuritySettings', () => {
|
||||
await user.type(input, 'secret');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('password', 's');
|
||||
});
|
||||
|
||||
it('should handle visitor password change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByLabelText('visitorPassword');
|
||||
await user.type(input, 'guest');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('visitorPassword', 'g');
|
||||
});
|
||||
|
||||
// Visitor mode switch has been removed - this test is no longer applicable
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Box, CardContent, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Video } from '../../types';
|
||||
import { formatDate } from '../../utils/formatUtils';
|
||||
import { formatRelativeDownloadTime } from '../../utils/formatUtils';
|
||||
import { VideoCardCollectionInfo } from '../../utils/videoCardUtils';
|
||||
|
||||
interface VideoCardContentProps {
|
||||
@@ -72,7 +72,7 @@ export const VideoCardContent: React.FC<VideoCardContentProps> = ({
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(video.date)}
|
||||
{formatRelativeDownloadTime(video.addedAt, video.date, t)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
|
||||
vi.mock('../../../utils/formatUtils', () => ({
|
||||
formatDate: () => '2023-01-01',
|
||||
formatRelativeDownloadTime: () => '2023-01-01',
|
||||
}));
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
|
||||
import { Video } from '../../types';
|
||||
import { formatDate, formatDuration } from '../../utils/formatUtils';
|
||||
@@ -107,7 +107,8 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
|
||||
onAddToCollection
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -162,7 +163,7 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && !visitorMode && (
|
||||
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && !isVisitor && (
|
||||
<Tooltip title={t('addToCollection')} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Check, Close, Edit, ExpandLess, ExpandMore } from '@mui/icons-material'
|
||||
import { Box, Button, TextField, Tooltip, Typography, useMediaQuery } from '@mui/material';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
interface EditableTitleProps {
|
||||
title: string;
|
||||
@@ -11,7 +11,8 @@ interface EditableTitleProps {
|
||||
|
||||
const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
|
||||
const [editedTitle, setEditedTitle] = useState<string>('');
|
||||
@@ -106,7 +107,7 @@ const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Tooltip title={t('editTitle')} disableHoverListener={isTouch}>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../../contexts/SnackbarContext';
|
||||
import { useVideo } from '../../../contexts/VideoContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useCloudStorageUrl } from '../../../hooks/useCloudStorageUrl';
|
||||
import { useShareVideo } from '../../../hooks/useShareVideo';
|
||||
import { Video } from '../../../types'; // Add imports
|
||||
@@ -29,7 +29,8 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
|
||||
const { t } = useLanguage();
|
||||
const { handleShare } = useShareVideo(video);
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const { incrementView } = useVideo();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -247,7 +248,7 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
|
||||
<Share />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<>
|
||||
{onToggleVisibility && (
|
||||
<Tooltip title={video.visibility === 0 ? t('showVideo') : t('hideVideo')} disableHoverListener={isTouch}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Notifications, NotificationsActive } from '@mui/icons-material';
|
||||
import { Avatar, Box, IconButton, Tooltip, Typography, useMediaQuery } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
interface VideoAuthorInfoProps {
|
||||
author: string;
|
||||
@@ -37,9 +37,10 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({
|
||||
onUnsubscribe
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const showSubscribeButton = (source === 'youtube' || source === 'bilibili') && !visitorMode;
|
||||
const showSubscribeButton = (source === 'youtube' || source === 'bilibili') && !isVisitor;
|
||||
|
||||
const handleSubscribeClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Add, Cast, Delete, MoreVert, Share, Visibility, VisibilityOff } from '@
|
||||
import { Button, IconButton, Menu, Stack, Tooltip, useMediaQuery } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
interface VideoKebabMenuButtonsProps {
|
||||
onPlayWith: (anchor: HTMLElement) => void;
|
||||
@@ -26,7 +26,8 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
|
||||
sx
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const [kebabMenuAnchor, setKebabMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
@@ -140,7 +141,7 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
|
||||
<Share />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<>
|
||||
{onToggleVisibility && (
|
||||
<Tooltip title={video?.visibility === 0 ? t('showVideo') : t('hideVideo')} disableHoverListener={isTouch}>
|
||||
|
||||
@@ -17,8 +17,8 @@ vi.mock('../../VideoCard', () => ({
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: false })
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ userRole: 'admin' })
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useCloudStorageUrl', () => ({
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('CollectionCard', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Test Collection/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 videos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders collection creation date', () => {
|
||||
@@ -131,7 +131,7 @@ describe('CollectionCard', () => {
|
||||
);
|
||||
|
||||
// Should show folder icon (via Material-UI icon)
|
||||
expect(screen.getByText(/0 videos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays up to 4 thumbnails in grid', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthProvider } from '../../contexts/AuthContext';
|
||||
import Header from '../Header';
|
||||
|
||||
// Mock contexts
|
||||
@@ -35,10 +36,16 @@ vi.mock('../../contexts/CollectionContext', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({
|
||||
visitorMode: false,
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
loginRequired: false,
|
||||
checkingAuth: false,
|
||||
userRole: 'admin',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock child components to avoid context dependency issues
|
||||
@@ -92,11 +99,13 @@ describe('Header', () => {
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Header {...defaultProps} {...props} />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Header {...defaultProps} {...props} />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
@@ -124,14 +133,14 @@ describe('Header', () => {
|
||||
// and fall back to the default name. We verify the component renders correctly either way.
|
||||
const logo = screen.getByAltText('MyTube Logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
|
||||
|
||||
// Wait for the component to stabilize after async operations
|
||||
await waitFor(() => {
|
||||
// The title should be either "TestTube" (if settings succeeds) or "MyTube" (default)
|
||||
const title = screen.queryByText('TestTube') || screen.queryByText('MyTube');
|
||||
expect(title).toBeInTheDocument();
|
||||
}, { timeout: 2000 });
|
||||
|
||||
|
||||
// Logo should always be present
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,16 @@ vi.mock('../../contexts/LanguageContext');
|
||||
vi.mock('../../contexts/CollectionContext');
|
||||
vi.mock('../../contexts/SnackbarContext');
|
||||
vi.mock('../../contexts/VideoContext');
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
loginRequired: false,
|
||||
checkingAuth: false,
|
||||
userRole: 'admin',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockVideo = {
|
||||
id: '123',
|
||||
|
||||
@@ -8,7 +8,8 @@ interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
loginRequired: boolean;
|
||||
checkingAuth: boolean;
|
||||
login: () => void;
|
||||
userRole: 'admin' | 'visitor' | null;
|
||||
login: (role?: 'admin' | 'visitor') => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -16,6 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [userRole, setUserRole] = useState<'admin' | 'visitor' | null>(null);
|
||||
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -25,21 +27,36 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Check if login is enabled in settings
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const response = await axios.get(`${API_URL}/settings`, {
|
||||
withCredentials: true
|
||||
});
|
||||
const { loginEnabled, isPasswordSet } = response.data;
|
||||
|
||||
// Login is required only if enabled AND a password is set
|
||||
// Login is required if loginEnabled is true (regardless of password or passkey)
|
||||
if (!loginEnabled || !isPasswordSet) {
|
||||
setLoginRequired(false);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setLoginRequired(true);
|
||||
// Check if already authenticated in this session
|
||||
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
|
||||
if (sessionAuth === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
// Check if already authenticated via HTTP-only cookie
|
||||
// Read role from cookie (non-HTTP-only cookie set by backend)
|
||||
const roleCookie = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('mytube_role='));
|
||||
|
||||
if (roleCookie) {
|
||||
const role = roleCookie.split('=')[1] as 'admin' | 'visitor';
|
||||
if (role === 'admin' || role === 'visitor') {
|
||||
setIsAuthenticated(true);
|
||||
setUserRole(role);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setUserRole(null);
|
||||
}
|
||||
} else {
|
||||
// No role cookie means not authenticated
|
||||
setIsAuthenticated(false);
|
||||
setUserRole(null);
|
||||
}
|
||||
}
|
||||
return response.data;
|
||||
@@ -50,19 +67,40 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
});
|
||||
|
||||
const login = () => {
|
||||
const login = (role?: 'admin' | 'visitor') => {
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem('mytube_authenticated', 'true');
|
||||
if (role) {
|
||||
setUserRole(role);
|
||||
}
|
||||
// Token is now stored in HTTP-only cookie by backend, no need to store it here
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
// Clear local state immediately
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem('mytube_authenticated');
|
||||
setUserRole(null);
|
||||
|
||||
// Clear role cookie from frontend (it's not HTTP-only, so we can clear it)
|
||||
// This prevents the auth check from seeing the cookie before backend clears it
|
||||
document.cookie = 'mytube_role=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
|
||||
try {
|
||||
// Call backend logout endpoint to clear HTTP-only cookies
|
||||
await axios.post(`${API_URL}/settings/logout`, {}, {
|
||||
withCredentials: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Continue with logout even if backend call fails
|
||||
}
|
||||
|
||||
// Invalidate and refetch auth settings to ensure fresh auth state
|
||||
queryClient.invalidateQueries({ queryKey: ['authSettings'] });
|
||||
queryClient.refetchQueries({ queryKey: ['authSettings'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, login, logout }}>
|
||||
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, userRole, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -91,6 +91,8 @@ export const DownloadProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
return hasActive || hasQueued ? 2000 : false;
|
||||
},
|
||||
initialData: initialStatus || { activeDownloads: [], queuedDownloads: [] },
|
||||
// Always fetch fresh data on mount to ensure we have the latest server state
|
||||
refetchOnMount: 'always',
|
||||
staleTime: 1000, // Consider data stale after 1 second
|
||||
gcTime: 5 * 60 * 1000, // Garbage collect after 5 minutes
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState
|
||||
import { Video } from '../types';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
import { useSnackbar } from './SnackbarContext';
|
||||
import { useVisitorMode } from './VisitorModeContext';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const MAX_SEARCH_RESULTS = 200; // Maximum number of search results to keep in memory
|
||||
@@ -51,7 +51,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
|
||||
// Videos Query
|
||||
const { data: videosRaw = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
|
||||
@@ -75,11 +76,11 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
|
||||
// Filter invisible videos when in visitor mode
|
||||
const videos = useMemo(() => {
|
||||
if (visitorMode) {
|
||||
if (isVisitor) {
|
||||
return videosRaw.filter(video => (video.visibility ?? 1) === 1);
|
||||
}
|
||||
return videosRaw;
|
||||
}, [videosRaw, visitorMode]);
|
||||
}, [videosRaw, isVisitor]);
|
||||
|
||||
// Settings Query (tags and showYoutubeSearch)
|
||||
const { data: settingsData } = useQuery({
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface VisitorModeContextType {
|
||||
visitorMode: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const VisitorModeContext = createContext<VisitorModeContextType>({
|
||||
visitorMode: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
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 visitorMode = settingsData?.visitorMode === true;
|
||||
|
||||
return (
|
||||
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
|
||||
{children}
|
||||
</VisitorModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVisitorMode = () => {
|
||||
const context = useContext(VisitorModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useVisitorMode must be used within a VisitorModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user