53 Commits

Author SHA1 Message Date
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
115 changed files with 7081 additions and 1876 deletions

View File

@@ -1,6 +1,120 @@
# Change Log
## 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

@@ -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,24 +1,28 @@
{
"name": "backend",
"version": "1.7.21",
"version": "1.7.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.7.21",
"version": "1.7.32",
"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",
@@ -30,6 +34,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 +633,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 +802,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 +831,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 +1353,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 +1428,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 +1449,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 +1498,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 +1510,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 +1533,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 +1545,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 +1567,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 +1590,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 +1607,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 +1628,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 +1639,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 +2019,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 +2387,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 +2695,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 +3178,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 +3520,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 +3565,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 +4503,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 +5468,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 +5567,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 +6484,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 +6574,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.32",
"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,15 +17,19 @@
"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",
@@ -36,6 +41,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";
@@ -46,10 +46,14 @@ export abstract class BaseDownloader implements IDownloader {
*/
protected async downloadThumbnail(
thumbnailUrl: string,
savePath: string
savePath: string,
axiosConfig: any = {}
): Promise<boolean> {
try {
logger.info("Downloading thumbnail from:", thumbnailUrl);
if (axiosConfig.proxy) {
logger.debug("Using proxy for thumbnail download");
}
// Ensure directory exists
fs.ensureDirSync(path.dirname(savePath));
@@ -58,6 +62,7 @@ export abstract class BaseDownloader implements IDownloader {
method: "GET",
url: thumbnailUrl,
responseType: "stream",
...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,12 @@ 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,
} from "../../utils/ytDlpUtils";
import * as storageService from "../storageService";
import { Video } from "../storageService";
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
@@ -150,41 +156,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;
// 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];
// 4. Select the best m3u8 URL from collected URLs
let m3u8Url = MissAVDownloader.selectBestM3u8Url(m3u8Urls, hasFormatSort);
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 +220,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 +261,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 +324,15 @@ 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 +390,21 @@ 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
const axiosConfig = userConfig.proxy
? getAxiosProxyConfig(userConfig.proxy)
: {};
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 +418,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 +429,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 +442,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 +458,133 @@ 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,6 +9,7 @@ import { ProgressTracker } from "../../../utils/progressTracker";
import {
executeYtDlpJson,
executeYtDlpSpawn,
getAxiosProxyConfig,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} from "../../../utils/ytDlpUtils";
@@ -55,9 +57,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 +258,14 @@ export async function downloadVideo(
let thumbnailSaved = false;
if (thumbnailUrl) {
// Use base class method via temporary instance
const axiosConfig = userConfig.proxy
? getAxiosProxyConfig(userConfig.proxy)
: {};
const downloader = new BilibiliDownloaderHelper();
thumbnailSaved = await downloader.downloadThumbnailPublic(
thumbnailUrl,
thumbnailPath
thumbnailPath,
axiosConfig
);
}
@@ -308,6 +315,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 +327,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 +433,27 @@ 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`;
const axiosConfig = userConfig.proxy
? getAxiosProxyConfig(userConfig.proxy)
: {};
subtitles = await downloadSubtitles(
url,
newSafeBaseFilename,
collectionName
subtitleDir,
subtitlePathPrefix,
axiosConfig
);
logger.info(`Downloaded ${subtitles.length} subtitles`);
} catch (e) {
@@ -462,9 +492,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,6 +11,8 @@ import { ProgressTracker } from "../../../utils/progressTracker";
import {
executeYtDlpJson,
executeYtDlpSpawn,
getAxiosProxyConfig,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} from "../../../utils/ytDlpUtils";
import * as storageService from "../../storageService";
@@ -44,9 +46,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 +89,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 +138,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 +165,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 +220,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 +301,17 @@ export async function downloadVideo(
thumbnailSaved = false;
if (thumbnailUrl) {
// Prepare axios config with proxy if available
let axiosConfig = {};
if (downloadUserConfig.proxy) {
axiosConfig = getAxiosProxyConfig(downloadUserConfig.proxy);
}
thumbnailSaved = await downloader.downloadThumbnailPublic(
thumbnailUrl,
newThumbnailPath
newThumbnailPath,
axiosConfig
);
}
@@ -271,13 +324,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 +351,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 +410,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

@@ -509,3 +509,38 @@ export function getNetworkConfigFromUserConfig(
return networkOptions;
}
/**
* Helper to convert a proxy URL string into an Axios config object
* Supports http/https proxies with authentication
* Format: http://user:pass@host:port
*/
export function getAxiosProxyConfig(proxyUrl: string): any {
if (!proxyUrl) return {};
try {
const url = new URL(proxyUrl);
const isHttps = url.protocol === "https:";
const defaultPort = isHttps ? 443 : 80;
// Axios proxy config structure
const proxyConfig: any = {
protocol: url.protocol.replace(":", ""),
host: url.hostname,
port: parseInt(url.port, 10) || defaultPort,
};
if (url.username || url.password) {
proxyConfig.auth = {
username: url.username,
password: url.password,
};
}
return { proxy: proxyConfig };
} catch (error) {
console.error("Invalid proxy URL:", proxyUrl);
return {};
}
}

View File

@@ -1,17 +1,18 @@
{
"name": "frontend",
"version": "1.7.21",
"version": "1.7.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.7.21",
"version": "1.7.32",
"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.32",
"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,7 @@ interface BasicSettingsProps {
const BasicSettings: React.FC<BasicSettingsProps> = ({ language, websiteName, onChange }) => {
const { t } = useLanguage();
const { userRole } = useAuth();
return (
<Box>
@@ -36,21 +38,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 } }}
/>
{userRole !== 'visitor' && (
<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

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

View File

@@ -15,7 +15,7 @@ const TestComponent = () => {
<div>
<div data-testid="auth-status">{isAuthenticated ? 'Authenticated' : 'Not Authenticated'}</div>
<div data-testid="login-required">{loginRequired ? 'Required' : 'Optional'}</div>
<button onClick={login}>Login</button>
<button onClick={() => login('admin')}>Login</button>
<button onClick={logout}>Logout</button>
</div>
);
@@ -42,8 +42,19 @@ const renderWithProviders = (ui: React.ReactNode) => {
describe('AuthContext', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
// Clear localStorage
if (typeof localStorage !== 'undefined' && localStorage.clear) {
localStorage.clear();
} else {
// Fallback for test environments
Object.keys(localStorage).forEach(key => {
delete (localStorage as any)[key];
});
}
document.cookie = '';
queryClient.clear();
// Mock axios.post for logout
(mockedAxios.post as any) = vi.fn().mockResolvedValue({});
});
it('should initialize with default authentication state', async () => {
@@ -88,8 +99,9 @@ describe('AuthContext', () => {
});
});
it('should check session storage for existing auth', async () => {
sessionStorage.setItem('mytube_authenticated', 'true');
it('should check local storage for existing auth', async () => {
// Set role cookie to simulate authenticated state
document.cookie = 'mytube_role=admin';
mockedAxios.get.mockResolvedValueOnce({
data: { loginEnabled: true, isPasswordSet: true }
});
@@ -101,7 +113,7 @@ describe('AuthContext', () => {
});
});
it('should require login if settings say so and no session', async () => {
it('should require login if settings say so and no stored auth', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { loginEnabled: true, isPasswordSet: true }
});
@@ -129,14 +141,15 @@ describe('AuthContext', () => {
await user.click(screen.getByText('Login'));
expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
expect(sessionStorage.getItem('mytube_authenticated')).toBe('true');
});
it('should handle logout', async () => {
sessionStorage.setItem('mytube_authenticated', 'true');
// Set role cookie to simulate authenticated state
document.cookie = 'mytube_role=admin';
mockedAxios.get.mockResolvedValueOnce({
data: { loginEnabled: true, isPasswordSet: true }
});
mockedAxios.post = vi.fn().mockResolvedValue({});
const user = userEvent.setup();
renderWithProviders(<TestComponent />);
@@ -147,7 +160,8 @@ describe('AuthContext', () => {
await user.click(screen.getByText('Logout'));
expect(screen.getByTestId('auth-status')).toHaveTextContent('Not Authenticated');
expect(sessionStorage.getItem('mytube_authenticated')).toBeNull();
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('Not Authenticated');
});
});
});

View File

@@ -9,6 +9,19 @@ import { LanguageProvider } from '../LanguageContext';
import { SnackbarProvider } from '../SnackbarContext';
import { VideoProvider } from '../VideoContext';
// Mock AuthContext
vi.mock('../AuthContext', () => ({
useAuth: () => ({
isAuthenticated: true,
loginRequired: false,
checkingAuth: false,
userRole: 'admin',
login: vi.fn(),
logout: vi.fn(),
}),
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('axios');
const mockedAxios = vi.mocked(axios, true);

View File

@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LanguageProvider } from '../LanguageContext';
import { SnackbarProvider } from '../SnackbarContext';
import { VideoProvider, useVideo } from '../VideoContext';
import { VisitorModeProvider } from '../VisitorModeContext';
import { AuthProvider } from '../AuthContext';
// Mock axios
vi.mock('axios');
@@ -21,9 +21,9 @@ const createWrapper = () => {
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<LanguageProvider>
<VisitorModeProvider>
<AuthProvider>
<VideoProvider>{children}</VideoProvider>
</VisitorModeProvider>
</AuthProvider>
</LanguageProvider>
</SnackbarProvider>
</QueryClientProvider>

View File

@@ -1,84 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VisitorModeProvider, useVisitorMode } from '../VisitorModeContext';
// Mock dependencies
vi.mock('axios');
const mockedAxios = vi.mocked(axios, true);
const TestComponent = () => {
const { visitorMode, isLoading } = useVisitorMode();
if (isLoading) return <div>Loading...</div>;
return <div data-testid="visitor-mode">{visitorMode ? 'Enabled' : 'Disabled'}</div>;
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<QueryClientProvider client={queryClient}>
<VisitorModeProvider>
{ui}
</VisitorModeProvider>
</QueryClientProvider>
);
};
describe('VisitorModeContext', () => {
beforeEach(() => {
vi.clearAllMocks();
queryClient.clear();
});
it('should fetch visitor mode settings', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { visitorMode: true }
});
renderWithProviders(<TestComponent />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('visitor-mode')).toHaveTextContent('Enabled');
});
});
it('should handle visitor mode disabled', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { visitorMode: false }
});
renderWithProviders(<TestComponent />);
await waitFor(() => {
expect(screen.getByTestId('visitor-mode')).toHaveTextContent('Disabled');
});
});
it('should return default values if used outside provider', () => {
// The context has a default value, so it doesn't throw, but returns that default.
// Default: visitorMode: false, isLoading: true
// We need a component to extract the value
let contextVal: any;
const Consumer = () => {
contextVal = useVisitorMode();
return null;
};
render(<Consumer />);
expect(contextVal).toBeDefined();
expect(contextVal.visitorMode).toBe(false);
expect(contextVal.isLoading).toBe(true);
});
});

View File

@@ -1,6 +1,7 @@
import { useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Video } from '../types';
const API_URL = import.meta.env.VITE_API_URL;
@@ -14,6 +15,7 @@ interface UseVideoProgressProps {
* Custom hook to manage video progress tracking and view counting
*/
export function useVideoProgress({ videoId, video }: UseVideoProgressProps) {
const { userRole } = useAuth();
const queryClient = useQueryClient();
const [hasViewed, setHasViewed] = useState<boolean>(false);
const lastProgressSave = useRef<number>(0);
@@ -29,20 +31,20 @@ export function useVideoProgress({ videoId, video }: UseVideoProgressProps) {
// Save progress on unmount
useEffect(() => {
return () => {
if (videoId && currentTimeRef.current > 0 && !isDeletingRef.current) {
if (videoId && currentTimeRef.current > 0 && !isDeletingRef.current && userRole !== 'visitor') {
axios.put(`${API_URL}/videos/${videoId}/progress`, {
progress: Math.floor(currentTimeRef.current)
})
.catch(err => console.error('Error saving progress on unmount:', err));
}
};
}, [videoId]);
}, [videoId, userRole]);
const handleTimeUpdate = (currentTime: number) => {
currentTimeRef.current = currentTime;
// Increment view count after 10 seconds
if (currentTime > 10 && !hasViewed && videoId) {
if (currentTime > 10 && !hasViewed && videoId && userRole !== 'visitor') {
setHasViewed(true);
axios.post(`${API_URL}/videos/${videoId}/view`)
.then(res => {
@@ -57,7 +59,7 @@ export function useVideoProgress({ videoId, video }: UseVideoProgressProps) {
// Save progress every 5 seconds
const now = Date.now();
if (now - lastProgressSave.current > 5000 && videoId) {
if (now - lastProgressSave.current > 5000 && videoId && userRole !== 'visitor') {
lastProgressSave.current = now;
axios.put(`${API_URL}/videos/${videoId}/progress`, {
progress: Math.floor(currentTime)

View File

@@ -1,4 +1,4 @@
import { ErrorOutline, LockOutlined, Refresh, Visibility, VisibilityOff } from '@mui/icons-material';
import { ErrorOutline, Fingerprint, InfoOutlined, LockOutlined, Refresh, Visibility, VisibilityOff } from '@mui/icons-material';
import {
Alert,
Avatar,
@@ -7,40 +7,54 @@ import {
CircularProgress,
Container,
CssBaseline,
Divider,
IconButton,
InputAdornment,
Tab,
Tabs,
TextField,
ThemeProvider,
Tooltip,
Typography
} from '@mui/material';
import { startAuthentication } from '@simplewebauthn/browser';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import logo from '../assets/logo.svg';
import AlertModal from '../components/AlertModal';
import ConfirmationModal from '../components/ConfirmationModal';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import getTheme from '../theme';
import { getWebAuthnErrorTranslationKey } from '../utils/translations';
const API_URL = import.meta.env.VITE_API_URL;
const LoginPage: React.FC = () => {
const [visitorPassword, setVisitorPassword] = useState('');
const [showVisitorPassword, setShowVisitorPassword] = useState(false);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [waitTime, setWaitTime] = useState(0); // in milliseconds
const [activeTab, setActiveTab] = useState(0); // 0 = Admin, 1 = Visitor
const [showResetModal, setShowResetModal] = useState(false);
const [alertOpen, setAlertOpen] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [websiteName, setWebsiteName] = useState('MyTube');
const [resetPasswordCooldown, setResetPasswordCooldown] = useState(0); // in milliseconds
const { t } = useLanguage();
const { login } = useAuth();
const queryClient = useQueryClient();
// Fetch website name from settings
// Fetch website name and settings from settings
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000, withCredentials: true });
return response.data;
} catch (error) {
return null;
@@ -50,6 +64,12 @@ const LoginPage: React.FC = () => {
retryDelay: 1000,
});
const passwordLoginAllowed = settingsData?.passwordLoginAllowed !== false;
const allowResetPassword = settingsData?.allowResetPassword !== false;
// Show visitor tab if visitor user is enabled AND visitorPassword is set
const visitorUserEnabled = settingsData?.visitorUserEnabled !== false;
const showVisitorTab = visitorUserEnabled && !!settingsData?.isVisitorPasswordSet;
// Update website name when settings are loaded
useEffect(() => {
if (settingsData && settingsData.websiteName) {
@@ -61,13 +81,52 @@ const LoginPage: React.FC = () => {
const { data: statusData, isLoading: isCheckingConnection, isError: isConnectionError, refetch: retryConnection } = useQuery({
queryKey: ['healthCheck'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings/password-enabled`, { timeout: 5000 });
const response = await axios.get(`${API_URL}/settings/password-enabled`, { timeout: 5000, withCredentials: true });
return response.data;
},
retry: 1,
retryDelay: 1000,
});
// Check if passkeys exist
const { data: passkeysData } = useQuery({
queryKey: ['passkeys-exists'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings/passkeys/exists`, { timeout: 5000, withCredentials: true });
return response.data;
} catch (error) {
return { exists: false };
}
},
retry: 1,
retryDelay: 1000,
enabled: !isCheckingConnection && !isConnectionError,
});
const passkeysExist = passkeysData?.exists || false;
// Fetch reset password cooldown from backend
const { data: cooldownData } = useQuery({
queryKey: ['resetPasswordCooldown'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings/reset-password-cooldown`, { timeout: 5000, withCredentials: true });
return response.data;
} catch (error) {
return { cooldown: 0 };
}
},
retry: 1,
retryDelay: 1000,
enabled: !isCheckingConnection && !isConnectionError,
refetchInterval: (query) => {
// Refetch every second if there's an active cooldown
const cooldown = query.state.data?.cooldown || 0;
return cooldown > 0 ? 1000 : false;
},
});
// Initialize wait time from server response
useEffect(() => {
if (statusData && statusData.waitTime) {
@@ -75,9 +134,16 @@ const LoginPage: React.FC = () => {
}
}, [statusData]);
// Auto-login if password is not enabled
// Update reset password cooldown from server response
useEffect(() => {
if (statusData && statusData.enabled === false) {
if (cooldownData && cooldownData.cooldown !== undefined) {
setResetPasswordCooldown(cooldownData.cooldown);
}
}, [cooldownData]);
// Auto-login only if login is not required
useEffect(() => {
if (statusData && statusData.loginRequired === false) {
login();
}
}, [statusData, login]);
@@ -95,6 +161,19 @@ const LoginPage: React.FC = () => {
}
}, [waitTime]);
// Countdown timer for reset password cooldown (updates local state while server refetches)
useEffect(() => {
if (resetPasswordCooldown > 0) {
const interval = setInterval(() => {
setResetPasswordCooldown((prev) => {
const newTime = prev - 1000;
return newTime > 0 ? newTime : 0;
});
}, 1000);
return () => clearInterval(interval);
}
}, [resetPasswordCooldown]);
// Use dark theme for login page to match app style
const theme = getTheme('dark');
@@ -110,68 +189,194 @@ const LoginPage: React.FC = () => {
return `${days} day${days !== 1 ? 's' : ''}`;
};
const loginMutation = useMutation({
mutationFn: async (password: string) => {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
const showAlert = (title: string, message: string) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertOpen(true);
};
const adminLoginMutation = useMutation({
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-admin-password`, { password: passwordToVerify }, { withCredentials: true });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
setWaitTime(0); // Reset wait time on success
login();
login(data.role);
} else {
setError(t('incorrectPassword'));
}
},
onError: (err: any) => {
console.error('Login error:', err);
if (err.response) {
const responseData = err.response.data;
if (err.response.status === 429) {
// Handle failures (incorrect password or too many attempts)
// These are returned as 200 OK with success: false to avoid console errors
const statusCode = data.statusCode || 401;
const responseData = data;
if (statusCode === 429) {
// Too many attempts - wait time required
const waitTimeMs = responseData.waitTime || 0;
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
setError(
`${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`
);
} else if (err.response.status === 401) {
showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else if (statusCode === 401) {
// Incorrect password - check if wait time is returned
const waitTimeMs = responseData.waitTime || 0;
if (waitTimeMs > 0) {
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
setError(
`${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`
);
showAlert(t('error'), `${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else {
setError(t('incorrectPassword'));
showAlert(t('error'), t('incorrectPassword'));
}
} else {
setError(t('loginFailed'));
showAlert(t('error'), t('loginFailed'));
}
} else {
setError(t('loginFailed'));
}
},
onError: (err: any) => {
console.error('Login error:', err);
// Handle actual network errors or unexpected 500s
showAlert(t('error'), t('loginFailed'));
}
});
// ...
const visitorLoginMutation = useMutation({
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-visitor-password`, { password: passwordToVerify }, { withCredentials: true });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
setWaitTime(0); // Reset wait time on success
// Token is now in HTTP-only cookie, role is in response
login(data.role);
} else {
// Handle failures (incorrect password or too many attempts)
const statusCode = data.statusCode || 401;
const responseData = data;
if (statusCode === 429) {
// Too many attempts - wait time required
const waitTimeMs = responseData.waitTime || 0;
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else if (statusCode === 401) {
// Incorrect password - check if wait time is returned
const waitTimeMs = responseData.waitTime || 0;
if (waitTimeMs > 0) {
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else {
showAlert(t('error'), t('incorrectPassword'));
}
} else {
showAlert(t('error'), t('loginFailed'));
}
}
},
onError: (err: any) => {
console.error('Login error:', err);
// Handle actual network errors or unexpected 500s
showAlert(t('error'), t('loginFailed'));
}
});
const handleVisitorSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (waitTime > 0) {
return;
}
setError('');
visitorLoginMutation.mutate(visitorPassword);
}
const resetPasswordMutation = useMutation({
mutationFn: async () => {
const response = await axios.post(`${API_URL}/settings/reset-password`);
const response = await axios.post(`${API_URL}/settings/reset-password`, {}, { withCredentials: true });
return response.data;
},
onSuccess: () => {
setShowResetModal(false);
setError('');
setWaitTime(0);
// Invalidate queries to refresh cooldown status
queryClient.invalidateQueries({ queryKey: ['healthCheck'] });
queryClient.invalidateQueries({ queryKey: ['resetPasswordCooldown'] });
// Show success message
alert(t('resetPasswordSuccess'));
showAlert(t('success'), t('resetPasswordSuccess'));
},
onError: (err: any) => {
console.error('Reset password error:', err);
setError(t('loginFailed'));
if (err.response && err.response.data && err.response.data.message) {
// Server returned a specific error message (likely cooldown)
showAlert(t('error'), err.response.data.message);
// Refresh cooldown status
queryClient.invalidateQueries({ queryKey: ['resetPasswordCooldown'] });
} else {
showAlert(t('error'), t('loginFailed'));
}
}
});
// Passkey authentication mutation
const passkeyLoginMutation = useMutation({
mutationFn: async () => {
// Step 1: Get authentication options
const optionsResponse = await axios.post(`${API_URL}/settings/passkeys/authenticate`, {}, { withCredentials: true });
const { options, challenge } = optionsResponse.data;
// Step 2: Start authentication with browser
const assertionResponse = await startAuthentication(options);
// Step 3: Verify authentication
const verifyResponse = await axios.post(`${API_URL}/settings/passkeys/authenticate/verify`, {
body: assertionResponse,
challenge,
}, { withCredentials: true });
if (!verifyResponse.data.success) {
throw new Error('Passkey authentication failed');
}
return verifyResponse.data;
},
onSuccess: (data) => {
setError('');
setWaitTime(0);
// Token is now in HTTP-only cookie, role is in response
if (data.role) {
login(data.role);
} else {
login(); // Fallback if no role returned (shouldn't happen with new backend)
}
},
onError: (err: any) => {
console.error('Passkey login error:', err);
// Extract error message from axios response or error object
let errorMessage = t('passkeyLoginFailed') || 'Passkey authentication failed. Please try again.';
if (err?.response?.data?.error) {
// Backend error message (e.g., "No passkeys registered" or "No passkeys found for RP_ID")
errorMessage = err.response.data.error;
} else if (err?.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (err?.message) {
errorMessage = err.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);
}
});
@@ -181,13 +386,39 @@ const LoginPage: React.FC = () => {
return; // Don't allow submission if wait time is active
}
setError('');
loginMutation.mutate(password);
adminLoginMutation.mutate(password);
};
const handleResetPassword = () => {
resetPasswordMutation.mutate();
};
const handlePasskeyLogin = () => {
// 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;
}
passkeyLoginMutation.mutate();
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
@@ -250,56 +481,192 @@ const LoginPage: React.FC = () => {
{t('signIn')}
</Typography>
</Box>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1, width: '100%' }}>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password')}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
<Box sx={{ mt: 1, width: '100%' }}>
{showVisitorTab && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_: React.SyntheticEvent, newValue: number) => setActiveTab(newValue)} aria-label="login tabs" variant="fullWidth">
<Tab label={t('admin') || 'Admin'} id="login-tab-0" aria-controls="login-tabpanel-0" />
<Tab label={t('visitorUser') || 'Visitor'} id="login-tab-1" aria-controls="login-tabpanel-1" />
</Tabs>
</Box>
)}
{/* Admin Tab Panel (and default view when visitor tab is not shown) */}
<div
role="tabpanel"
hidden={showVisitorTab && activeTab !== 0}
id="login-tabpanel-0"
aria-labelledby="login-tab-0"
>
{(showVisitorTab ? activeTab === 0 : true) && (
<>
{passwordLoginAllowed && (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password') || 'Admin Password'}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus={!showVisitorTab || activeTab === 0}
disabled={waitTime > 0 || adminLoginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={adminLoginMutation.isPending || waitTime > 0}
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending}
>
{t('resetPassword')}
</Button>
{adminLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
</Button>
</Box>
)}
{passwordLoginAllowed && passkeysExist && (
<>
<Divider sx={{ my: 2 }}>OR</Divider>
<Button
fullWidth
variant="outlined"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
</>
)}
{!passwordLoginAllowed && passkeysExist && (
<Button
fullWidth
variant="contained"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mt: 3, mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
)}
{allowResetPassword && (
<Button
fullWidth
variant="outlined"
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending || resetPasswordCooldown > 0}
>
{resetPasswordCooldown > 0
? `${t('resetPassword')} (${formatWaitTime(resetPasswordCooldown)})`
: t('resetPassword')}
</Button>
)}
{!allowResetPassword && passwordLoginAllowed && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 2 }}>
<Tooltip title={t('resetPasswordDisabledInfo') || 'Click for information about resetting password'}>
<IconButton
onClick={() => showAlert(
t('resetPassword') || 'Reset Password',
t('resetPasswordDisabledInfo') || 'Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.'
)}
color="primary"
sx={{
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<InfoOutlined />
</IconButton>
</Tooltip>
</Box>
)}
</>
)}
</div>
{/* Visitor Tab Panel */}
{showVisitorTab && (
<div
role="tabpanel"
hidden={activeTab !== 1}
id="login-tabpanel-1"
aria-labelledby="login-tab-1"
>
{activeTab === 1 && (
<Box component="form" onSubmit={handleVisitorSubmit} noValidate>
<TextField
margin="normal"
required
fullWidth
name="visitorPassword"
label={t('visitorPassword') || 'Visitor Password'}
type={showVisitorPassword ? 'text' : 'password'}
id="visitorPassword"
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
autoFocus={activeTab === 1}
disabled={waitTime > 0 || visitorLoginMutation.isPending}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowVisitorPassword(!showVisitorPassword)}
edge="end"
>
{showVisitorPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={visitorLoginMutation.isPending || waitTime > 0}
>
{visitorLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
</Button>
</Box>
)}
</div>
)}
<Box sx={{ minHeight: waitTime > 0 || (error && waitTime === 0) ? 'auto' : 0, mt: 2 }}>
{waitTime > 0 && (
<Alert severity="warning" sx={{ width: '100%' }}>
@@ -322,11 +689,17 @@ const LoginPage: React.FC = () => {
onClose={() => setShowResetModal(false)}
onConfirm={handleResetPassword}
title={t('resetPasswordTitle')}
message={t('resetPasswordMessage')}
message={`${t('resetPasswordMessage')}\n\n${t('resetPasswordScriptGuide')}`}
confirmText={t('resetPasswordConfirm')}
cancelText={t('cancel')}
isDanger={true}
/>
<AlertModal
open={alertOpen}
onClose={() => setAlertOpen(false)}
title={alertTitle}
message={alertMessage}
/>
</ThemeProvider>
);
};

View File

@@ -18,7 +18,7 @@ import VideosTable from '../components/ManagePage/VideosTable';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useAuth } from '../contexts/AuthContext';
import { useVideo } from '../contexts/VideoContext';
import { Collection, Video } from '../types';
import { formatSize } from '../utils/formatUtils';
@@ -29,7 +29,8 @@ const ManagePage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState<string>('');
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const { visitorMode } = useVisitorMode();
const { userRole } = useAuth();
const isVisitor = userRole === 'visitor';
const { videos, deleteVideo, refreshThumbnail, updateVideo } = useVideo();
const { collections, deleteCollection } = useCollection();
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -178,7 +179,7 @@ const ManagePage: React.FC = () => {
<Typography variant="h4" component="h1" fontWeight="bold">
{t('manageContent')}
</Typography>
{!visitorMode && (
{!isVisitor && (
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant="outlined"

View File

@@ -27,11 +27,10 @@ import InterfaceDisplaySettings from '../components/Settings/InterfaceDisplaySet
import SecuritySettings from '../components/Settings/SecuritySettings';
import TagsSettings from '../components/Settings/TagsSettings';
import VideoDefaultSettings from '../components/Settings/VideoDefaultSettings';
import VisitorModeSettings from '../components/Settings/VisitorModeSettings';
import YtDlpSettings from '../components/Settings/YtDlpSettings';
import { useAuth } from '../contexts/AuthContext';
import { useDownload } from '../contexts/DownloadContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useSettingsModals } from '../hooks/useSettingsModals';
import { useSettingsMutations } from '../hooks/useSettingsMutations';
import { useStickyButton } from '../hooks/useStickyButton';
@@ -45,7 +44,8 @@ const API_URL = import.meta.env.VITE_API_URL;
const SettingsPage: React.FC = () => {
const { t, setLanguage } = useLanguage();
const { activeDownloads } = useDownload();
const { visitorMode } = useVisitorMode();
const { userRole } = useAuth();
const isVisitor = userRole === 'visitor';
const [settings, setSettings] = useState<Settings>({
loginEnabled: false,
@@ -92,7 +92,7 @@ const SettingsPage: React.FC = () => {
const isSticky = useStickyButton(observerTarget);
// Fetch settings
const { data: settingsData } = useQuery({
const { data: settingsData, refetch } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
@@ -100,6 +100,10 @@ const SettingsPage: React.FC = () => {
}
});
useEffect(() => {
refetch();
}, [refetch]);
useEffect(() => {
if (settingsData) {
const newSettings = {
@@ -183,7 +187,7 @@ const SettingsPage: React.FC = () => {
</Grid>
{/* 2. Interface & Display */}
{!visitorMode && (
{!isVisitor && (
<Grid size={12}>
<CollapsibleSection title={t('interfaceDisplay')} defaultExpanded={false}>
<InterfaceDisplaySettings
@@ -198,45 +202,35 @@ const SettingsPage: React.FC = () => {
)}
{/* 3. Security & Access */}
<Grid size={12}>
<CollapsibleSection title={t('securityAccess')} defaultExpanded={false}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<VisitorModeSettings
visitorMode={settings.visitorMode}
savedVisitorMode={settingsData?.visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
{!isVisitor && userRole !== 'visitor' && (
<Grid size={12}>
<CollapsibleSection title={t('securityAccess')} defaultExpanded={false}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Box>
<Box>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Box>
<Box>
<CloudflareSettings
enabled={settings.cloudflaredTunnelEnabled}
token={settings.cloudflaredToken}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Box>
</Box>
{!visitorMode && (
<>
<Box>
<SecuritySettings
settings={settings}
onChange={handleChange}
/>
</Box>
<Box>
<CookieSettings
onSuccess={(msg) => setMessage({ text: msg, type: 'success' })}
onError={(msg) => setMessage({ text: msg, type: 'error' })}
/>
</Box>
</>
)}
<Box>
<CloudflareSettings
enabled={settings.cloudflaredTunnelEnabled}
token={settings.cloudflaredToken}
visitorMode={visitorMode}
onChange={(field, value) => handleChange(field as keyof Settings, value)}
/>
</Box>
</Box>
</CollapsibleSection>
</Grid>
</CollapsibleSection>
</Grid>
)}
{!visitorMode && (
{!isVisitor && (
<>
{/* 4. Video Playback */}
<Grid size={12}>

View File

@@ -20,7 +20,7 @@ import React, { useState } from 'react';
import ConfirmationModal from '../components/ConfirmationModal';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useAuth } from '../contexts/AuthContext';
import { TranslationKey } from '../utils/translations';
const API_URL = import.meta.env.VITE_API_URL;
@@ -59,7 +59,8 @@ interface ContinuousDownloadTask {
const SubscriptionsPage: React.FC = () => {
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const { visitorMode } = useVisitorMode();
const { userRole } = useAuth();
const isVisitor = userRole === 'visitor';
const [isUnsubscribeModalOpen, setIsUnsubscribeModalOpen] = useState(false);
const [selectedSubscription, setSelectedSubscription] = useState<{ id: string; author: string } | null>(null);
const [isCancelTaskModalOpen, setIsCancelTaskModalOpen] = useState(false);
@@ -201,13 +202,13 @@ const SubscriptionsPage: React.FC = () => {
<TableCell>{t('interval')}</TableCell>
<TableCell>{t('lastCheck')}</TableCell>
<TableCell>{t('downloads')}</TableCell>
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
{!isVisitor && <TableCell align="right">{t('actions')}</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={visitorMode ? 5 : 6} align="center">
<TableCell colSpan={isVisitor ? 5 : 6} align="center">
<Typography color="text.secondary" sx={{ py: 4 }}>
{t('noVideos')} {/* Reusing "No videos found" or similar if "No subscriptions" key missing */}
</Typography>
@@ -230,7 +231,7 @@ const SubscriptionsPage: React.FC = () => {
<TableCell>{sub.interval} {t('minutes')}</TableCell>
<TableCell>{formatDate(sub.lastCheck)}</TableCell>
<TableCell>{sub.downloadCount}</TableCell>
{!visitorMode && (
{!isVisitor && (
<TableCell align="right">
<IconButton
color="error"
@@ -254,7 +255,7 @@ const SubscriptionsPage: React.FC = () => {
<Typography variant="h5" component="h2" fontWeight="bold">
{t('continuousDownloadTasks')}
</Typography>
{!visitorMode && (
{!isVisitor && (
<Button
variant="outlined"
color="error"
@@ -277,7 +278,7 @@ const SubscriptionsPage: React.FC = () => {
<TableCell>{t('downloaded')}</TableCell>
<TableCell>{t('skipped')}</TableCell>
<TableCell>{t('failed')}</TableCell>
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
{!isVisitor && <TableCell align="right">{t('actions')}</TableCell>}
</TableRow>
</TableHead>
<TableBody>
@@ -314,7 +315,7 @@ const SubscriptionsPage: React.FC = () => {
<TableCell>{task.downloadedCount}</TableCell>
<TableCell>{task.skippedCount}</TableCell>
<TableCell>{task.failedCount}</TableCell>
{!visitorMode && (
{!isVisitor && (
<TableCell align="right">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{task.status !== 'completed' && task.status !== 'cancelled' && (

View File

@@ -17,7 +17,7 @@ import VideoControls from '../components/VideoPlayer/VideoControls';
import VideoInfo from '../components/VideoPlayer/VideoInfo';
import { useLanguage } from '../contexts/LanguageContext';
import { useVideo } from '../contexts/VideoContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useAuth } from '../contexts/AuthContext';
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { useVideoCollections } from '../hooks/useVideoCollections';
import { useVideoMutations } from '../hooks/useVideoMutations';
@@ -32,7 +32,8 @@ const VideoPlayer: React.FC = () => {
const navigate = useNavigate();
const { t } = useLanguage();
const { videos } = useVideo();
const { visitorMode } = useVisitorMode();
const { userRole } = useAuth();
const isVisitor = userRole === 'visitor';
const [showComments, setShowComments] = useState<boolean>(false);
const [autoPlayNext, setAutoPlayNext] = useState<boolean>(() => {
@@ -71,13 +72,13 @@ const VideoPlayer: React.FC = () => {
return () => clearTimeout(timer);
}
// In visitor mode, redirect if video is invisible
if (visitorMode && video && (video.visibility ?? 1) === 0) {
if (isVisitor && video && (video.visibility ?? 1) === 0) {
const timer = setTimeout(() => {
navigate('/');
}, 3000);
return () => clearTimeout(timer);
}
}, [error, navigate, visitorMode, video]);
}, [error, navigate, isVisitor, video]);
// Use video player settings hook
const {

View File

@@ -5,7 +5,10 @@ import SettingsPage from '../SettingsPage';
// Mock all external hooks and components
const mockSettingsData = { data: {} };
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => mockSettingsData),
useQuery: vi.fn(() => ({
...mockSettingsData,
refetch: vi.fn(),
})),
useMutation: vi.fn(),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
@@ -27,9 +30,9 @@ vi.mock('../../contexts/DownloadContext', () => ({
})),
}));
vi.mock('../../contexts/VisitorModeContext', () => ({
useVisitorMode: vi.fn(() => ({
visitorMode: false,
vi.mock('../../contexts/AuthContext', () => ({
useAuth: vi.fn(() => ({
userRole: 'admin',
})),
}));
@@ -148,7 +151,7 @@ describe('SettingsPage', () => {
expect(screen.getByTestId('basic-settings')).toBeInTheDocument();
expect(screen.getByTestId('interface-display-settings')).toBeInTheDocument();
expect(screen.getByTestId('cloudflare-settings')).toBeInTheDocument();
// Since visitorMode is mocked to false, these should be visible
// Since userRole is mocked to 'admin', these should be visible
expect(screen.getByTestId('cookie-settings')).toBeInTheDocument();
expect(screen.getByTestId('security-settings')).toBeInTheDocument();
expect(screen.getByTestId('video-default-settings')).toBeInTheDocument();

View File

@@ -59,6 +59,9 @@ export interface Settings {
loginEnabled: boolean;
password?: string;
isPasswordSet?: boolean;
passwordLoginAllowed?: boolean;
allowResetPassword?: boolean;
isVisitorPasswordSet?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
@@ -79,7 +82,8 @@ export interface Settings {
proxyOnlyYoutube?: boolean;
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
visitorPassword?: string;
visitorUserEnabled?: boolean;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;

View File

@@ -1,143 +1,297 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatDuration, formatSize, parseDuration } from '../formatUtils';
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatDate,
formatDuration,
formatRelativeDownloadTime,
formatSize,
parseDuration,
} from "../formatUtils";
describe('formatUtils', () => {
describe('parseDuration', () => {
it('should return 0 for undefined', () => {
expect(parseDuration(undefined)).toBe(0);
});
it('should return number as-is', () => {
expect(parseDuration(100)).toBe(100);
expect(parseDuration(0)).toBe(0);
});
it('should parse HH:MM:SS format', () => {
expect(parseDuration('1:30:45')).toBe(5445); // 1*3600 + 30*60 + 45 = 3600 + 1800 + 45
expect(parseDuration('0:5:30')).toBe(330); // 0*3600 + 5*60 + 30 = 0 + 300 + 30
expect(parseDuration('2:0:0')).toBe(7200); // 2*3600 + 0*60 + 0 = 7200
});
it('should parse MM:SS format', () => {
expect(parseDuration('5:30')).toBe(330); // 5*60 + 30
expect(parseDuration('10:15')).toBe(615); // 10*60 + 15
expect(parseDuration('0:45')).toBe(45);
});
it('should parse numeric string', () => {
expect(parseDuration('100')).toBe(100);
expect(parseDuration('0')).toBe(0);
});
it('should return 0 for invalid string', () => {
expect(parseDuration('invalid')).toBe(0);
// 'abc:def' will be parsed as NaN for each part, but the function
// will try parseInt on the whole string which also returns NaN -> 0
expect(parseDuration('abc:def')).toBe(0);
expect(parseDuration('not-a-number')).toBe(0);
});
describe("formatUtils", () => {
describe("parseDuration", () => {
it("should return 0 for undefined", () => {
expect(parseDuration(undefined)).toBe(0);
});
describe('formatDuration', () => {
it('should return 00:00 for undefined', () => {
expect(formatDuration(undefined)).toBe('00:00');
});
it('should return formatted string as-is if already formatted', () => {
expect(formatDuration('1:30:45')).toBe('1:30:45');
expect(formatDuration('5:30')).toBe('5:30');
});
it('should format seconds to MM:SS', () => {
expect(formatDuration(65)).toBe('1:05'); // 1 minute 5 seconds
expect(formatDuration(125)).toBe('2:05'); // 2 minutes 5 seconds
expect(formatDuration(45)).toBe('0:45'); // 45 seconds
expect(formatDuration(0)).toBe('00:00');
});
it('should format seconds to H:MM:SS for hours', () => {
expect(formatDuration(3665)).toBe('1:01:05'); // 1 hour 1 minute 5 seconds
expect(formatDuration(3600)).toBe('1:00:00'); // 1 hour
expect(formatDuration(7325)).toBe('2:02:05'); // 2 hours 2 minutes 5 seconds
});
it('should format numeric string', () => {
expect(formatDuration('65')).toBe('1:05');
expect(formatDuration('3665')).toBe('1:01:05');
});
it('should return 00:00 for invalid input', () => {
expect(formatDuration('invalid')).toBe('00:00');
expect(formatDuration(NaN)).toBe('00:00');
});
it("should return number as-is", () => {
expect(parseDuration(100)).toBe(100);
expect(parseDuration(0)).toBe(0);
});
describe('formatSize', () => {
it('should return "0 B" for undefined', () => {
expect(formatSize(undefined)).toBe('0 B');
});
it('should format bytes', () => {
expect(formatSize(0)).toBe('0 B');
expect(formatSize(500)).toBe('500 B');
expect(formatSize(1023)).toBe('1023 B');
});
it('should format kilobytes', () => {
expect(formatSize(1024)).toBe('1 KB');
expect(formatSize(1536)).toBe('1.5 KB');
expect(formatSize(2048)).toBe('2 KB');
expect(formatSize(10240)).toBe('10 KB');
});
it('should format megabytes', () => {
expect(formatSize(1048576)).toBe('1 MB'); // 1024 * 1024
expect(formatSize(1572864)).toBe('1.5 MB');
expect(formatSize(5242880)).toBe('5 MB');
});
it('should format gigabytes', () => {
expect(formatSize(1073741824)).toBe('1 GB'); // 1024^3
expect(formatSize(2147483648)).toBe('2 GB');
});
it('should format terabytes', () => {
expect(formatSize(1099511627776)).toBe('1 TB'); // 1024^4
});
it('should format numeric string', () => {
expect(formatSize('1024')).toBe('1 KB');
expect(formatSize('1048576')).toBe('1 MB');
});
it('should return "0 B" for invalid input', () => {
expect(formatSize('invalid')).toBe('0 B');
expect(formatSize(NaN)).toBe('0 B');
});
it("should parse HH:MM:SS format", () => {
expect(parseDuration("1:30:45")).toBe(5445); // 1*3600 + 30*60 + 45 = 3600 + 1800 + 45
expect(parseDuration("0:5:30")).toBe(330); // 0*3600 + 5*60 + 30 = 0 + 300 + 30
expect(parseDuration("2:0:0")).toBe(7200); // 2*3600 + 0*60 + 0 = 7200
});
describe('formatDate', () => {
it('should return "Unknown date" for undefined', () => {
expect(formatDate(undefined)).toBe('Unknown date');
});
it('should return "Unknown date" for invalid length', () => {
expect(formatDate('202301')).toBe('Unknown date');
expect(formatDate('202301011')).toBe('Unknown date');
expect(formatDate('2023')).toBe('Unknown date');
});
it('should format YYYYMMDD to YYYY-MM-DD', () => {
expect(formatDate('20230101')).toBe('2023-01-01');
expect(formatDate('20231225')).toBe('2023-12-25');
expect(formatDate('20200101')).toBe('2020-01-01');
expect(formatDate('20230228')).toBe('2023-02-28');
});
it('should handle edge cases', () => {
expect(formatDate('19991231')).toBe('1999-12-31');
expect(formatDate('20991231')).toBe('2099-12-31');
});
it("should parse MM:SS format", () => {
expect(parseDuration("5:30")).toBe(330); // 5*60 + 30
expect(parseDuration("10:15")).toBe(615); // 10*60 + 15
expect(parseDuration("0:45")).toBe(45);
});
it("should parse numeric string", () => {
expect(parseDuration("100")).toBe(100);
expect(parseDuration("0")).toBe(0);
});
it("should return 0 for invalid string", () => {
expect(parseDuration("invalid")).toBe(0);
// 'abc:def' will be parsed as NaN for each part, but the function
// will try parseInt on the whole string which also returns NaN -> 0
expect(parseDuration("abc:def")).toBe(0);
expect(parseDuration("not-a-number")).toBe(0);
});
});
describe("formatDuration", () => {
it("should return 00:00 for undefined", () => {
expect(formatDuration(undefined)).toBe("00:00");
});
it("should return formatted string as-is if already formatted", () => {
expect(formatDuration("1:30:45")).toBe("1:30:45");
expect(formatDuration("5:30")).toBe("5:30");
});
it("should format seconds to MM:SS", () => {
expect(formatDuration(65)).toBe("1:05"); // 1 minute 5 seconds
expect(formatDuration(125)).toBe("2:05"); // 2 minutes 5 seconds
expect(formatDuration(45)).toBe("0:45"); // 45 seconds
expect(formatDuration(0)).toBe("00:00");
});
it("should format seconds to H:MM:SS for hours", () => {
expect(formatDuration(3665)).toBe("1:01:05"); // 1 hour 1 minute 5 seconds
expect(formatDuration(3600)).toBe("1:00:00"); // 1 hour
expect(formatDuration(7325)).toBe("2:02:05"); // 2 hours 2 minutes 5 seconds
});
it("should format numeric string", () => {
expect(formatDuration("65")).toBe("1:05");
expect(formatDuration("3665")).toBe("1:01:05");
});
it("should return 00:00 for invalid input", () => {
expect(formatDuration("invalid")).toBe("00:00");
expect(formatDuration(NaN)).toBe("00:00");
});
});
describe("formatSize", () => {
it('should return "0 B" for undefined', () => {
expect(formatSize(undefined)).toBe("0 B");
});
it("should format bytes", () => {
expect(formatSize(0)).toBe("0 B");
expect(formatSize(500)).toBe("500 B");
expect(formatSize(1023)).toBe("1023 B");
});
it("should format kilobytes", () => {
expect(formatSize(1024)).toBe("1 KB");
expect(formatSize(1536)).toBe("1.5 KB");
expect(formatSize(2048)).toBe("2 KB");
expect(formatSize(10240)).toBe("10 KB");
});
it("should format megabytes", () => {
expect(formatSize(1048576)).toBe("1 MB"); // 1024 * 1024
expect(formatSize(1572864)).toBe("1.5 MB");
expect(formatSize(5242880)).toBe("5 MB");
});
it("should format gigabytes", () => {
expect(formatSize(1073741824)).toBe("1 GB"); // 1024^3
expect(formatSize(2147483648)).toBe("2 GB");
});
it("should format terabytes", () => {
expect(formatSize(1099511627776)).toBe("1 TB"); // 1024^4
});
it("should format numeric string", () => {
expect(formatSize("1024")).toBe("1 KB");
expect(formatSize("1048576")).toBe("1 MB");
});
it('should return "0 B" for invalid input', () => {
expect(formatSize("invalid")).toBe("0 B");
expect(formatSize(NaN)).toBe("0 B");
});
});
describe("formatDate", () => {
it('should return "Unknown date" for undefined', () => {
expect(formatDate(undefined)).toBe("Unknown date");
});
it('should return "Unknown date" for invalid length', () => {
expect(formatDate("202301")).toBe("Unknown date");
expect(formatDate("202301011")).toBe("Unknown date");
expect(formatDate("2023")).toBe("Unknown date");
});
it("should format YYYYMMDD to YYYY-MM-DD", () => {
expect(formatDate("20230101")).toBe("2023-01-01");
expect(formatDate("20231225")).toBe("2023-12-25");
expect(formatDate("20200101")).toBe("2020-01-01");
expect(formatDate("20230228")).toBe("2023-02-28");
});
it("should handle edge cases", () => {
expect(formatDate("19991231")).toBe("1999-12-31");
expect(formatDate("20991231")).toBe("2099-12-31");
});
});
describe("formatRelativeDownloadTime", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const mockTranslation = (
key: string,
replacements?: Record<string, string | number>
) => {
const translations: Record<string, string> = {
justNow: "Just now",
hoursAgo: `${replacements?.hours || 0} hours ago`,
today: "Today",
thisWeek: "This week",
weeksAgo: `${replacements?.weeks || 0} weeks ago`,
unknownDate: "Unknown date",
};
return translations[key] || key;
};
it('should return "Just now" for less than 1 hour', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const thirtyMinutesAgo = new Date("2023-01-01T11:30:00Z").toISOString();
expect(
formatRelativeDownloadTime(thirtyMinutesAgo, undefined, mockTranslation)
).toBe("Just now");
});
it('should return "X hours ago" for 1-5 hours', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const twoHoursAgo = new Date("2023-01-01T10:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(twoHoursAgo, undefined, mockTranslation)
).toBe("2 hours ago");
});
it('should return "Today" for 5-24 hours', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const tenHoursAgo = new Date("2023-01-01T02:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(tenHoursAgo, undefined, mockTranslation)
).toBe("Today");
});
it('should return "This week" for 1-7 days', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const threeDaysAgo = new Date("2022-12-29T12:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(threeDaysAgo, undefined, mockTranslation)
).toBe("This week");
});
it('should return "X weeks ago" for 1-4 weeks', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const twoWeeksAgo = new Date("2022-12-18T12:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(twoWeeksAgo, undefined, mockTranslation)
).toBe("2 weeks ago");
});
it("should return formatted date for > 4 weeks", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const sixWeeksAgo = new Date("2022-11-20T12:00:00Z").toISOString();
const result = formatRelativeDownloadTime(
sixWeeksAgo,
"20221120",
mockTranslation
);
expect(result).toBe("2022-11-20");
});
it("should use originalDate when provided for > 4 weeks", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const sixWeeksAgo = new Date("2022-11-20T12:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(sixWeeksAgo, "20221120", mockTranslation)
).toBe("2022-11-20");
});
it('should fallback to "Unknown date" when no timestamp provided', () => {
expect(
formatRelativeDownloadTime(undefined, undefined, mockTranslation)
).toBe("Unknown date");
});
it("should use originalDate when no timestamp provided", () => {
expect(
formatRelativeDownloadTime(undefined, "20230101", mockTranslation)
).toBe("2023-01-01");
});
it("should fallback to English when no translation function provided", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const thirtyMinutesAgo = new Date("2023-01-01T11:30:00Z").toISOString();
expect(formatRelativeDownloadTime(thirtyMinutesAgo)).toBe("Just now");
});
it("should handle invalid date", () => {
expect(
formatRelativeDownloadTime("invalid-date", undefined, mockTranslation)
).toBe("Unknown date");
});
it("should use originalDate when date is invalid", () => {
expect(
formatRelativeDownloadTime("invalid-date", "20230101", mockTranslation)
).toBe("2023-01-01");
});
it("should format date in UTC to avoid timezone issues", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
// Use a date that could be affected by timezone (midnight UTC)
const sixWeeksAgo = new Date("2022-11-20T00:00:00Z").toISOString();
// Should format as 2022-11-20 regardless of system timezone
const result = formatRelativeDownloadTime(
sixWeeksAgo,
undefined,
mockTranslation
);
expect(result).toBe("2022-11-20");
});
it("should handle date formatting across timezone boundaries", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
// Test with a date near midnight UTC to catch timezone edge cases
const sixWeeksAgo = new Date("2022-11-20T23:59:59Z").toISOString();
const result = formatRelativeDownloadTime(
sixWeeksAgo,
undefined,
mockTranslation
);
expect(result).toBe("2022-11-20");
});
});
});

Some files were not shown because too many files have changed in this diff Show More