62 Commits

Author SHA1 Message Date
Peifan Li
899bb8ee2e chore(release): v1.7.34 2026-01-04 23:39:56 -05:00
Peifan Li
dc51b4405a feat: Add isVisitor check to BasicSettings and useVideoProgress 2026-01-04 23:39:18 -05:00
Peifan Li
3e44960ce7 feat: Add isVisitor check to BasicSettings and useVideoProgress 2026-01-04 23:35:45 -05:00
Peifan Li
91d53f04a4 feat: Add refetchOnMount option to DownloadProvider 2026-01-04 23:24:39 -05:00
Peifan Li
e91ae4b314 chore(release): v1.7.33 2026-01-04 22:06:04 -05:00
Peifan Li
a49dd31feb feat: Add SOCKS5 proxy support in axios config 2026-01-04 22:05:20 -05:00
Peifan Li
494b85d440 refactor: Add default timeout for thumbnail downloads 2026-01-04 22:03:41 -05:00
Peifan Li
695489d72a feat: Add support for SOCKS5 proxies in axios config 2026-01-04 21:50:01 -05:00
Peifan Li
a4eaaa3180 chore: update doc 2026-01-04 17:08:53 -05:00
Peifan Li
79530dbca2 chore(release): v1.7.32 2026-01-04 13:53:17 -05:00
Peifan Li
f48066c045 style: Update cookie security settings for better usability 2026-01-04 13:52:34 -05:00
Peifan Li
46c8d7730f style: Update cookie security settings for better usability 2026-01-04 13:49:30 -05:00
Peifan Li
fbd55b0037 chore(release): v1.7.31 2026-01-04 13:37:08 -05:00
Peifan Li
6490e1f912 feat: Import 'vitest' for testing utilities 2026-01-04 13:36:25 -05:00
Peifan Li
16ba5ac1d4 feat: Add new features and refactor code for version 1.7.31 2026-01-04 13:25:57 -05:00
Peifan Li
f76acfdcf1 feat: Implement helper for selecting best m3u8 URL 2026-01-04 13:08:38 -05:00
Peifan Li
98ec0b342f feat: Add executeYtDlpJson function 2026-01-04 12:42:34 -05:00
Peifan Li
c995eb3637 refactor: Update axios configuration for downloading subtitles 2026-01-04 12:32:28 -05:00
Peifan Li
8e533e3615 chore(release): v1.7.30 2026-01-04 00:19:07 -05:00
Peifan Li
7dbf5c895d test: Update mock SettingsPage test to include refetch 2026-01-04 00:18:23 -05:00
Peifan Li
eeac567523 refactor: Update mock SettingsPage test to include refetch 2026-01-04 00:17:41 -05:00
Peifan Li
10c857865c style: Improve comments and add tests in v1.7.30 2026-01-04 00:13:31 -05:00
Peifan Li
e7bdf182c5 style: Improve comments for YtDlpSettings file 2026-01-04 00:12:02 -05:00
Peifan Li
a5e82b9e81 test: Add file_location test and mock settings in ytdlpVideo 2026-01-03 23:58:13 -05:00
Peifan Li
d99a210174 chore(release): v1.7.29 2026-01-03 23:31:56 -05:00
Peifan Li
50cc94a44e feat: Add visitor mode in LoginPage component 2026-01-03 23:31:20 -05:00
Peifan Li
ccd2729f71 feat: Enable visitor user with password option 2026-01-03 23:28:30 -05:00
Peifan Li
a9f78647e4 test: Add role to response in passwordController tests 2026-01-03 22:49:12 -05:00
Peifan Li
e18f49d321 feat: enhance visitor mode 2026-01-03 22:40:34 -05:00
Peifan Li
13de853a54 feat: enhance visitor mode 2026-01-03 22:07:04 -05:00
Peifan Li
76d4269164 feat: enhance visitor mode 2026-01-03 21:47:54 -05:00
Peifan Li
44b24543d0 feat: Add visitor mode in LoginPage component 2026-01-03 16:24:13 -05:00
Peifan Li
b6fbf015a3 chore(release): v1.7.28 2026-01-03 15:48:46 -05:00
Peifan Li
9c0afb0693 refactor: Improve m3u8 URL selection strategy 2026-01-03 15:48:05 -05:00
Peifan Li
3717296bf2 refactor: Improve m3u8 URL selection strategy 2026-01-03 13:43:31 -05:00
Peifan Li
fe8dd04f08 chore(release): v1.7.27 2026-01-03 13:01:13 -05:00
Peifan Li
e0819ca42c feat: Add new features for password reset and WebAuthn 2026-01-03 13:00:28 -05:00
Peifan Li
092a79f635 feat: Add endpoint for retrieving reset password cooldown 2026-01-03 12:58:35 -05:00
Peifan Li
9296390b82 feat: Add WebAuthn error translations 2026-01-03 12:43:56 -05:00
Peifan Li
35aa348824 chore(release): v1.7.26 2026-01-03 11:39:55 -05:00
Peifan Li
1b9451bffa feat: Add script to reset password securely 2026-01-03 11:38:31 -05:00
Peifan Li
9968268975 feat: Add allowResetPassword setting and UI components 2026-01-03 11:23:03 -05:00
Peifan Li
ce544ff9c2 feat: Add password login permission handling 2026-01-03 11:05:42 -05:00
Peifan Li
b6e3072350 chore(release): v1.7.25 2026-01-02 23:45:02 -05:00
Peifan Li
85424624ca feat: Add passkey feature and refactor formatUtils 2026-01-02 23:44:20 -05:00
Peifan Li
6fdfa90d01 feat: add passkey feature 2026-01-02 23:42:56 -05:00
Peifan Li
c9657bad51 refactor: Update formatUtils to use formatRelativeDownloadTime function 2026-01-02 13:25:02 -05:00
Peifan Li
2d9d7b37a6 chore(release): v1.7.24 2026-01-01 12:16:05 -05:00
Peifan Li
b8fcb05d51 refactor: Explicitly preserve network-related options 2026-01-01 12:15:20 -05:00
Peifan Li
90a24454f6 refactor: Explicitly preserve network-related options 2026-01-01 12:13:19 -05:00
Peifan Li
a56de30dd1 chore(release): v1.7.23 2026-01-01 11:31:14 -05:00
Peifan Li
b8cc540f9d fix: Correct version number in CHANGELOG to v1.7.23 2026-01-01 11:29:56 -05:00
Peifan Li
b546a4520e feat: Add new features and dependencies updates 2026-01-01 11:29:27 -05:00
Peifan Li
6bbb40eb11 feat: Add logic to refresh thumbnail with random timestamp 2026-01-01 11:27:07 -05:00
Peifan Li
c00b552ba9 feat: Add reset password route and update dependencies 2026-01-01 11:17:15 -05:00
Peifan Li
845e1847f7 feat: Add reset password route 2026-01-01 11:15:02 -05:00
Peifan Li
71d59a9e26 Merge pull request #53 from franklioxygen/snyk-fix-6d6192da51ce3a14e4e8b5488c3c7e83 2025-12-31 00:22:38 -05:00
Peifan Li
4e8d7553ea chore(release): v1.7.22 2025-12-30 23:09:09 -05:00
Peifan Li
e1fb345094 feat: Add risk command scanning for hook uploads 2025-12-30 23:08:30 -05:00
Peifan Li
351f1876d7 refactor: Improve handling of absolute paths in security functions 2025-12-30 23:06:50 -05:00
Peifan Li
c32fa3e7ca feat: Add risk command scanning for hook uploads 2025-12-30 23:00:38 -05:00
snyk-bot
b0428b9813 fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-QS-14724253
2025-12-31 03:26:55 +00:00
128 changed files with 7745 additions and 2673 deletions

View File

@@ -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

View File

@@ -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` 覆盖默认值。
## 数据库

View File

@@ -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

View 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
);

View 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": {}
}
}

View File

@@ -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
}
]
}

View File

@@ -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": {

View File

@@ -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",

View 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);
});

View File

@@ -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

View File

@@ -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
}));
});
});

View File

@@ -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'
])
);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});
});

View File

@@ -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

View 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();
});
});
});

View File

@@ -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', () => {

View File

@@ -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({

View File

@@ -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', () => {

View File

@@ -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
*/

View 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 });
};

View File

@@ -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" });
};

View File

@@ -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 },
});
};

View File

@@ -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"

View File

@@ -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
}
);

View 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();
};

View 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();
};

View 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();
};

View File

@@ -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.",
});
};

View File

@@ -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.",
});
};

View File

@@ -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));

View File

@@ -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) => {

View 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: []
});
});
});
});

View 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;
};

View File

@@ -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);

View File

@@ -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"
);
}
}

View File

@@ -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];
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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,

View 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;
}
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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(

View File

@@ -17,7 +17,6 @@ describe('settings types', () => {
websiteName: "MyTube",
itemsPerPage: 12,
showYoutubeSearch: true,
visitorMode: false,
infiniteScroll: false,
videoColumns: 4,
pauseOnFocusLoss: false,

View File

@@ -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,
};

View File

@@ -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("..")) {

View File

@@ -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))
);
}
}

View File

@@ -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 }`

View File

@@ -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

View File

@@ -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 repos `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
```

View File

@@ -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

View File

@@ -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.

View File

@@ -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 }`
- 支持: YouTubeBilibiliMissAV 以及所有 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 }`

View File

@@ -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`: 应用配置

View File

@@ -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
```

View File

@@ -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

View File

@@ -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 后端相同的权限执行。
> - 使用修改或删除文件的命令时请务必小心。
> - 请勿复制粘贴来自不可信来源的脚本。
> - 确保您的脚本能够优雅地处理错误。
> - 确保脚本能正确处理错误。

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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">

View File

@@ -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>
);
};

View File

@@ -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

View File

@@ -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 />}

View File

@@ -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

View File

@@ -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}

View File

@@ -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

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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', () => ({

View File

@@ -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 >
);
};

View File

@@ -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."}
/>
)}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View File

@@ -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
});

View File

@@ -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')}

View File

@@ -10,6 +10,7 @@ vi.mock('../../../contexts/LanguageContext', () => ({
vi.mock('../../../utils/formatUtils', () => ({
formatDate: () => '2023-01-01',
formatRelativeDownloadTime: () => '2023-01-01',
}));

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}>

View File

@@ -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();

View File

@@ -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}>

View File

@@ -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', () => ({

View File

@@ -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', () => {

View File

@@ -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();
});

View File

@@ -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',

View File

@@ -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>
);

View File

@@ -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
});

View File

@@ -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({

View File

@@ -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