44 Commits

Author SHA1 Message Date
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
108 changed files with 6738 additions and 1814 deletions

View File

@@ -1,6 +1,103 @@
# Change Log
## 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.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.7.21",
"version": "1.7.30",
"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.30",
"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

@@ -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),
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,90 @@
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)
const isProduction = process.env.NODE_ENV === "production";
const isSecure = process.env.SECURE_COOKIES === "true" || isProduction;
res.cookie(COOKIE_NAME, token, {
httpOnly: true, // Not accessible to JavaScript
secure: isSecure, // Only sent over HTTPS in production
sameSite: "strict", // CSRF protection
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: "strict",
maxAge: maxAge,
path: "/",
});
};
/**
* Clear authentication cookies
*/
export const clearAuthCookie = (res: Response): void => {
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure: process.env.SECURE_COOKIES === "true" || process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
res.clearCookie("mytube_role", {
httpOnly: false,
secure: process.env.SECURE_COOKIES === "true" || process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
};
/**
* Get cookie name for authentication token
*/
export const getAuthCookieName = (): string => {
return COOKIE_NAME;
};

View File

@@ -8,6 +8,11 @@ import { cleanupTemporaryFiles, safeRemove } from "../../utils/downloadUtils";
import { formatVideoFilename } from "../../utils/helpers";
import { logger } from "../../utils/logger";
import { ProgressTracker } from "../../utils/progressTracker";
import {
flagsToArgs,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} from "../../utils/ytDlpUtils";
import * as storageService from "../storageService";
import { Video } from "../storageService";
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
@@ -150,33 +155,50 @@ export class MissAVDownloader extends BaseDownloader {
thumbnail: thumbnailUrl,
});
// 3. Select the best m3u8 URL from collected URLs
// Prefer specific quality playlists over master playlists
// 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);
// 4. Select the best m3u8 URL from collected URLs
// If user specified format sort, prefer master playlists so yt-dlp can choose resolution
// Otherwise, prefer specific quality playlists
let m3u8Url: string | null = null;
if (m3u8Urls.length > 0) {
// Sort URLs: prefer specific quality playlists, avoid master playlists
// Sort URLs based on whether user wants format sort
const sortedUrls = m3u8Urls.sort((a, b) => {
const aIsMaster =
a.includes("/playlist.m3u8") || a.includes("/master/");
const bIsMaster =
b.includes("/playlist.m3u8") || b.includes("/master/");
// 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
if (hasFormatSort) {
// When format sort is specified, prefer master playlists
// so yt-dlp can apply format sort to choose the right resolution
if (aIsMaster && !bIsMaster) return -1; // Master playlist first
if (!aIsMaster && bIsMaster) return 1;
// Among master playlists or non-master playlists, prefer higher quality
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
} else {
// Default behavior: prefer specific quality playlists over 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];
logger.info(
`Selected m3u8 URL from ${m3u8Urls.length} candidates:`,
`Selected m3u8 URL from ${m3u8Urls.length} candidates (format sort: ${hasFormatSort}):`,
m3u8Url
);
if (sortedUrls.length > 1) {
@@ -184,7 +206,7 @@ export class MissAVDownloader extends BaseDownloader {
}
}
// 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 +251,26 @@ 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);
// 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 +286,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 +349,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,7 +415,7 @@ 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 downloader = new MissAVDownloader();
@@ -367,7 +425,7 @@ export class MissAVDownloader extends BaseDownloader {
);
}
// 8. Get video duration
// 9. Get video duration
let duration: string | undefined;
try {
const { getVideoDuration } = await import(
@@ -381,7 +439,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 +450,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,
@@ -419,19 +477,52 @@ 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;
}
}

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,7 +32,8 @@ 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 }> =
[];
@@ -64,11 +65,20 @@ export async function processSubtitles(
);
const language = match ? match[1] : "unknown";
// Move subtitle to subtitles directory
// Move subtitle to subtitles directory or keep in video directory if requested
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
const destSubFilename = `${baseFilename}.${language}.vtt`;
const destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
let destSubPath: string;
let webPath: string;
if (moveSubtitlesToVideoFolder) {
destSubPath = path.join(VIDEOS_DIR, destSubFilename);
webPath = `/videos/${destSubFilename}`;
} else {
destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
webPath = `/subtitles/${destSubFilename}`;
}
// Read VTT file and fix alignment for centering
let vttContent = fs.readFileSync(sourceSubPath, "utf-8");
// Replace align:start with align:middle for centered subtitles
@@ -79,8 +89,14 @@ export async function processSubtitles(
// Write cleaned VTT to destination
fs.writeFileSync(destSubPath, vttContent, "utf-8");
// Remove original file
fs.unlinkSync(sourceSubPath);
// 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 +105,7 @@ export async function processSubtitles(
subtitles.push({
language,
filename: destSubFilename,
path: `/subtitles/${destSubFilename}`,
path: webPath,
});
}
} catch (subtitleError) {

View File

@@ -2,16 +2,17 @@ import fs from "fs-extra";
import path from "path";
import { IMAGES_DIR, VIDEOS_DIR } from "../../../config/paths";
import {
cleanupSubtitleFiles,
cleanupVideoArtifacts,
cleanupSubtitleFiles,
cleanupVideoArtifacts,
} from "../../../utils/downloadUtils";
import { formatVideoFilename } from "../../../utils/helpers";
import { logger } from "../../../utils/logger";
import { ProgressTracker } from "../../../utils/progressTracker";
import {
executeYtDlpJson,
executeYtDlpSpawn,
getUserYtDlpConfig,
executeYtDlpJson,
executeYtDlpSpawn,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} from "../../../utils/ytDlpUtils";
import * as storageService from "../../storageService";
import { Video } from "../../storageService";
@@ -86,8 +87,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 +136,16 @@ export async function downloadVideo(
finalThumbnailFilename = newThumbnailFilename;
// Update paths
const settings = storageService.getSettings();
const moveThumbnailsToVideoFolder =
settings.moveThumbnailsToVideoFolder || false;
const moveSubtitlesToVideoFolder =
settings.moveSubtitlesToVideoFolder || false;
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 +156,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 +211,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);
}
@@ -271,13 +307,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 +334,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 +393,9 @@ export async function downloadVideo(
? finalThumbnailFilename
: existingVideo.thumbnailFilename,
thumbnailPath: thumbnailSaved
? `/images/${finalThumbnailFilename}`
? moveThumbnailsToVideoFolder
? `/videos/${finalThumbnailFilename}`
: `/images/${finalThumbnailFilename}`
: existingVideo.thumbnailPath,
duration: videoData.duration,
fileSize: videoData.fileSize,

View File

@@ -0,0 +1,343 @@
import type {
GenerateAuthenticationOptionsOpts,
GenerateRegistrationOptionsOpts,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
} from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { logger } from "../utils/logger";
import { generateToken } from "./authService";
import * as storageService from "./storageService";
// RP (Relying Party) configuration
const rpName = "MyTube";
const rpID = process.env.RP_ID || "localhost"; // Default to localhost for development
export const defaultOrigin = process.env.ORIGIN || `http://${rpID}:5550`; // Frontend origin
const origin = defaultOrigin;
// Storage key for passkeys
const PASSKEYS_STORAGE_KEY = "passkeys";
interface StoredPasskey {
credentialID: string; // Base64url encoded
credentialPublicKey: string; // Base64 encoded
counter: number;
transports?: string[];
id: string; // Same as credentialID for convenience
name?: string;
createdAt: string;
rpID?: string; // Store the RP_ID used during registration for debugging
origin?: string; // Store the origin used during registration for debugging
}
/**
* Get all stored passkeys
*/
export function getPasskeys(): StoredPasskey[] {
try {
const settings = storageService.getSettings();
const passkeys = settings[PASSKEYS_STORAGE_KEY];
if (!passkeys || !Array.isArray(passkeys)) {
return [];
}
return passkeys;
} catch (error) {
logger.error(
"Error getting passkeys",
error instanceof Error ? error : new Error(String(error))
);
return [];
}
}
/**
* Save passkeys to storage
*/
function savePasskeys(passkeys: StoredPasskey[]): void {
try {
storageService.saveSettings({
[PASSKEYS_STORAGE_KEY]: passkeys,
});
} catch (error) {
logger.error(
"Error saving passkeys",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
/**
* Generate registration options for creating a new passkey
*/
export async function generatePasskeyRegistrationOptions(
userName: string = "MyTube User",
originOverride?: string,
rpIDOverride?: string
): Promise<{
options: any;
challenge: string;
}> {
const existingPasskeys = getPasskeys();
const effectiveRPID = rpIDOverride || rpID;
const opts: GenerateRegistrationOptionsOpts = {
rpName,
rpID: effectiveRPID,
userID: Buffer.from(userName),
userName,
timeout: 60000,
attestationType: "none",
excludeCredentials: existingPasskeys.map((passkey) => ({
id: Buffer.from(passkey.credentialID, "base64url").toString("base64url"),
type: "public-key" as const,
transports: passkey.transports as any,
})),
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "preferred",
requireResidentKey: false,
},
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
};
const options = await generateRegistrationOptions(opts);
// Store challenge temporarily (in a real app, you'd use a session or cache)
// For simplicity, we'll return it and the frontend will send it back
return {
options,
challenge: options.challenge,
};
}
/**
* Verify and store a new passkey
*/
export async function verifyPasskeyRegistration(
body: any,
challenge: string,
originOverride?: string,
rpIDOverride?: string
): Promise<{ verified: boolean; passkey?: StoredPasskey }> {
try {
const existingPasskeys = getPasskeys();
const effectiveRPID = rpIDOverride || rpID;
const effectiveOrigin = originOverride || origin;
logger.info(
`Verifying passkey registration with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
);
const opts: VerifyRegistrationResponseOpts = {
response: body,
expectedChallenge: challenge,
expectedOrigin: effectiveOrigin,
expectedRPID: effectiveRPID,
requireUserVerification: false,
};
const verification = await verifyRegistrationResponse(opts);
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = Buffer.from(credential.publicKey).toString(
"base64"
);
const newPasskey: StoredPasskey = {
credentialID,
credentialPublicKey,
counter: credential.counter || 0,
transports: body.response.transports || credential.transports || [],
id: credentialID,
name: body.name || `Passkey ${existingPasskeys.length + 1}`,
createdAt: new Date().toISOString(),
rpID: effectiveRPID, // Store RP_ID for debugging
origin: effectiveOrigin, // Store origin for debugging
};
logger.info(
`Passkey registered successfully with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
);
const updatedPasskeys = [...existingPasskeys, newPasskey];
savePasskeys(updatedPasskeys);
logger.info("New passkey registered successfully");
return { verified: true, passkey: newPasskey };
}
return { verified: false };
} catch (error) {
logger.error(
"Error verifying passkey registration",
error instanceof Error ? error : new Error(String(error))
);
return { verified: false };
}
}
/**
* Generate authentication options for passkey login
*/
export async function generatePasskeyAuthenticationOptions(
rpIDOverride?: string
): Promise<{
options: any;
challenge: string;
}> {
const passkeys = getPasskeys();
if (passkeys.length === 0) {
throw new Error("No passkeys registered");
}
const effectiveRPID = rpIDOverride || rpID;
logger.info(
`Generating authentication options with RP_ID: ${effectiveRPID}, Found ${passkeys.length} passkey(s)`
);
// Log stored RP_IDs for debugging
const storedRPIDs = passkeys.map((p) => p.rpID || "not set");
logger.info(`Stored passkeys RP_IDs: ${storedRPIDs.join(", ")}`);
// Filter passkeys to only include those that match the current RP_ID
// This is critical - browsers will only find passkeys that match the RP_ID
const matchingPasskeys = passkeys.filter((passkey) => {
// If passkey has stored RP_ID, it must match
if (passkey.rpID) {
return passkey.rpID === effectiveRPID;
}
// For passkeys without stored RP_ID (legacy data), include them as fallback
// This allows old passkeys to still work
return true;
});
logger.info(
`Using ${matchingPasskeys.length} passkey(s) matching RP_ID: ${effectiveRPID}`
);
if (matchingPasskeys.length === 0) {
throw new Error(
`No passkeys found for RP_ID: ${effectiveRPID}. Please create a new passkey.`
);
}
// Since we only allow platform authenticators during registration (authenticatorAttachment: "platform"),
// all passkeys should be platform authenticators. Explicitly set transports to ["internal"]
// to ensure the browser uses the platform authenticator (fingerprint/face ID) instead of
// falling back to cross-platform authentication (QR code)
const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: matchingPasskeys.map((passkey) => ({
id: passkey.credentialID,
type: "public-key" as const,
// Always specify "internal" transport since we only register platform authenticators
// This tells the browser to use the device's built-in authenticator (fingerprint/face ID)
transports: ["internal"] as any,
})),
userVerification: "preferred",
rpID: effectiveRPID,
};
const options = await generateAuthenticationOptions(opts);
return {
options,
challenge: options.challenge,
};
}
/**
* Verify passkey authentication
*/
/**
* Verify passkey authentication
*/
export async function verifyPasskeyAuthentication(
body: any,
challenge: string,
originOverride?: string,
rpIDOverride?: string
): Promise<{ verified: boolean; token?: string; role?: "admin" | "visitor" }> {
try {
const passkeys = getPasskeys();
// Find passkey by matching the credential ID
// body.id is already in base64url format from the browser
const passkey = passkeys.find((p) => p.credentialID === body.id);
if (!passkey) {
logger.warn("Passkey not found for authentication");
return { verified: false };
}
const effectiveRPID = rpIDOverride || rpID;
const effectiveOrigin = originOverride || origin;
const opts: VerifyAuthenticationResponseOpts = {
response: body,
expectedChallenge: challenge,
expectedOrigin: effectiveOrigin,
expectedRPID: effectiveRPID,
credential: {
id: passkey.credentialID,
publicKey: Buffer.from(passkey.credentialPublicKey, "base64") as any,
counter: passkey.counter,
transports: passkey.transports as any,
},
requireUserVerification: false,
};
const verification = await verifyAuthenticationResponse(opts);
if (verification.verified) {
// Update counter
const updatedPasskeys = passkeys.map((p) =>
p.credentialID === passkey.credentialID
? { ...p, counter: verification.authenticationInfo.newCounter }
: p
);
savePasskeys(updatedPasskeys);
logger.info("Passkey authentication successful");
// Generate admin token (Passkeys are currently only for admins)
const token = generateToken({ role: "admin" });
return { verified: true, token, role: "admin" };
}
return { verified: false };
} catch (error) {
logger.error(
"Error verifying passkey authentication",
error instanceof Error ? error : new Error(String(error))
);
return { verified: false };
}
}
/**
* Remove all passkeys
*/
export function removeAllPasskeys(): void {
try {
savePasskeys([]);
logger.info("All passkeys removed");
} catch (error) {
logger.error(
"Error removing passkeys",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}

View File

@@ -1,9 +1,18 @@
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { defaultSettings } from "../types/settings";
import { logger } from "../utils/logger";
import * as loginAttemptService from "./loginAttemptService";
import * as storageService from "./storageService";
import { logger } from "../utils/logger";
import { Settings, defaultSettings } from "../types/settings";
/**
* Check if login is required (loginEnabled is true)
*/
export function isLoginRequired(): boolean {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
return mergedSettings.loginEnabled === true;
}
/**
* Check if password authentication is enabled
@@ -11,12 +20,16 @@ import { Settings, defaultSettings } from "../types/settings";
export function isPasswordEnabled(): {
enabled: boolean;
waitTime?: number;
loginRequired?: boolean;
} {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Return true only if login is enabled AND a password is set
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
// Return true only if login is enabled AND a password is set AND password login is allowed
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password && passwordLoginAllowed;
// Check for remaining wait time
const remainingWaitTime = loginAttemptService.canAttemptLogin();
@@ -24,16 +37,22 @@ export function isPasswordEnabled(): {
return {
enabled: isEnabled,
waitTime: remainingWaitTime > 0 ? remainingWaitTime : undefined,
loginRequired: mergedSettings.loginEnabled === true,
};
}
/**
* Verify password for authentication
* @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security
*/
import { generateToken } from "./authService";
export async function verifyPassword(
password: string
): Promise<{
success: boolean;
role?: "admin" | "visitor";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
@@ -41,9 +60,19 @@ export async function verifyPassword(
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
if (!mergedSettings.password) {
// If no password set but login enabled, allow access
return { success: true };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
// If password login is explicitly disabled, ONLY allow Admin to login via password (if they have one set)?
// Or just block everyone? The frontend says "When disabled, password login is not available."
// But typically Admin should always be able to login?
// For now, let's respect the flag, but maybe we should allow it if we are matching the Admin password?
// Let's stick to current logic: if blocked, blocked.
if (!passwordLoginAllowed) {
return {
success: false,
message: "Password login is not allowed. Please use passkey authentication.",
};
}
// Check if user can attempt login (wait time check)
@@ -57,24 +86,187 @@ export async function verifyPassword(
};
}
const isMatch = await bcrypt.compare(password, mergedSettings.password);
if (isMatch) {
// Reset failed attempts on successful login
loginAttemptService.resetFailedAttempts();
return { success: true };
// 1. Check Admin Password
if (mergedSettings.password) {
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
if (isAdminMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
} else {
// Record failed attempt and get wait time
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
// If no admin password set, and login enabled, allow as admin
if (mergedSettings.loginEnabled) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
}
// 2. Check Visitor Password (if visitorPassword is set and visitor user is enabled)
// Permission control is now based on user role
// If password matches visitorPassword, assign visitor role
const visitorUserEnabled = mergedSettings.visitorUserEnabled !== false;
if (visitorUserEnabled && mergedSettings.visitorPassword) {
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
if (isVisitorMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "visitor" });
return { success: true, role: "visitor", token };
}
}
// No match
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect password",
};
}
/**
* Verify admin password for authentication
* Only checks admin password, not visitor password
*/
export async function verifyAdminPassword(
password: string
): Promise<{
success: boolean;
role?: "admin";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
}> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect password",
message: "Password login is not allowed. Please use passkey authentication.",
};
}
// Check if user can attempt login (wait time check)
const remainingWaitTime = loginAttemptService.canAttemptLogin();
if (remainingWaitTime > 0) {
return {
success: false,
waitTime: remainingWaitTime,
message: "Too many failed attempts. Please wait before trying again.",
};
}
// Check Admin Password only
if (mergedSettings.password) {
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
if (isAdminMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
} else {
// If no admin password set, and login enabled, allow as admin
if (mergedSettings.loginEnabled) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
}
// No match - record failed attempt
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect admin password",
};
}
/**
* Verify visitor password for authentication
* Only checks visitor password, not admin password
*/
export async function verifyVisitorPassword(
password: string
): Promise<{
success: boolean;
role?: "visitor";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
}> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if visitor user is enabled (defaults to true for backward compatibility)
const visitorUserEnabled = mergedSettings.visitorUserEnabled !== false;
if (!visitorUserEnabled) {
return {
success: false,
message: "Visitor user is not enabled.",
};
}
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
return {
success: false,
message: "Password login is not allowed. Please use passkey authentication.",
};
}
// Check if user can attempt login (wait time check)
const remainingWaitTime = loginAttemptService.canAttemptLogin();
if (remainingWaitTime > 0) {
return {
success: false,
waitTime: remainingWaitTime,
message: "Too many failed attempts. Please wait before trying again.",
};
}
// Check Visitor Password only
if (mergedSettings.visitorPassword) {
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
if (isVisitorMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "visitor" });
return { success: true, role: "visitor", token };
}
} else {
// No visitor password set
return {
success: false,
message: "Visitor password is not configured.",
};
}
// No match - record failed attempt
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect visitor password",
};
}
/**
@@ -85,11 +277,57 @@ export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, salt);
}
const RESET_PASSWORD_COOLDOWN = 60 * 60 * 1000; // 1 hour in milliseconds
/**
* Get the remaining cooldown time for password reset
* Returns the remaining time in milliseconds, or 0 if no cooldown
*/
export function getResetPasswordCooldown(): number {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
const lastResetTime = (mergedSettings as any).lastPasswordResetTime as number | undefined;
if (!lastResetTime) {
return 0;
}
const timeSinceLastReset = Date.now() - lastResetTime;
const remainingCooldown = RESET_PASSWORD_COOLDOWN - timeSinceLastReset;
return remainingCooldown > 0 ? remainingCooldown : 0;
}
/**
* Reset password to a random 8-character string
* Returns the new password (should be logged, not sent to frontend)
*/
export async function resetPassword(): Promise<string> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password reset is allowed (defaults to true for backward compatibility)
const allowResetPassword = mergedSettings.allowResetPassword !== false;
if (!allowResetPassword) {
throw new Error("Password reset is not allowed. The allowResetPassword setting is disabled.");
}
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
throw new Error("Password reset is not allowed when password login is disabled");
}
// Check cooldown period (1 hour)
const remainingCooldown = getResetPasswordCooldown();
if (remainingCooldown > 0) {
const minutes = Math.ceil(remainingCooldown / (60 * 1000));
throw new Error(`Password reset is on cooldown. Please wait ${minutes} minute${minutes !== 1 ? 's' : ''} before trying again.`);
}
// Generate random 8-character password using cryptographically secure random
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -101,11 +339,10 @@ export async function resetPassword(): Promise<string> {
// Hash the new password
const hashedPassword = await hashPassword(newPassword);
// Update settings with new password
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Update settings with new password and reset timestamp
mergedSettings.password = hashedPassword;
mergedSettings.loginEnabled = true; // Ensure login is enabled
(mergedSettings as any).lastPasswordResetTime = Date.now();
storageService.saveSettings(mergedSettings);

View File

@@ -1,6 +1,6 @@
import * as storageService from "./storageService";
import { Settings, defaultSettings } from "../types/settings";
import { logger } from "../utils/logger";
import * as storageService from "./storageService";
/**
* Validate and normalize settings values
@@ -25,54 +25,6 @@ export function validateSettings(newSettings: Partial<Settings>): void {
}
}
/**
* Check if visitor mode restrictions should apply
*/
export function checkVisitorModeRestrictions(
existingSettings: Settings,
newSettings: Partial<Settings>
): {
allowed: boolean;
error?: string;
} {
// If visitor mode is not enabled, no restrictions
if (existingSettings.visitorMode !== true) {
return { allowed: true };
}
// If visitorMode is being explicitly set to false, allow the update
if (newSettings.visitorMode === false) {
return { allowed: true };
}
// If visitorMode is explicitly set to true (already enabled), allow but only update visitorMode
if (newSettings.visitorMode === true) {
return { allowed: true };
}
// Allow CloudFlare tunnel settings updates (read-only access mechanism, doesn't violate visitor mode)
const isOnlyCloudflareUpdate =
(newSettings.cloudflaredTunnelEnabled !== undefined ||
newSettings.cloudflaredToken !== undefined) &&
Object.keys(newSettings).every(
(key) =>
key === "cloudflaredTunnelEnabled" ||
key === "cloudflaredToken" ||
key === "visitorMode"
);
if (isOnlyCloudflareUpdate) {
return { allowed: true };
}
// Block all other changes
return {
allowed: false,
error:
"Visitor mode is enabled. Only disabling visitor mode or updating CloudFlare settings is allowed.",
};
}
/**
* Process tag deletions and update videos accordingly
*/
@@ -137,14 +89,31 @@ export async function prepareSettingsForSave(
const prepared = { ...newSettings };
// Handle password hashing
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = existingSettings.passwordLoginAllowed !== false;
if (prepared.password) {
// If password is provided, hash it
prepared.password = await hashPassword(prepared.password);
// If password login is not allowed, reject password updates
if (!passwordLoginAllowed) {
// Remove password from prepared settings to prevent update
delete prepared.password;
logger.warn("Password update rejected: password login is not allowed");
} else {
// If password is provided and allowed, hash it
prepared.password = await hashPassword(prepared.password);
}
} else {
// If password is empty/not provided, keep existing password
prepared.password = existingSettings.password;
}
// Handle visitor password hashing
if (prepared.visitorPassword) {
prepared.visitorPassword = await hashPassword(prepared.visitorPassword);
} else {
prepared.visitorPassword = existingSettings.visitorPassword;
}
// Handle tags
const oldTags: string[] = existingSettings.tags || [];
if (prepared.tags === undefined) {

View File

@@ -10,29 +10,29 @@ export { initializeStorage } from "./initialization";
// Download Status
export {
addActiveDownload,
updateActiveDownload,
getDownloadStatus,
removeActiveDownload,
setQueuedDownloads,
getDownloadStatus,
updateActiveDownload,
} from "./downloadStatus";
// Download History
export {
addDownloadHistoryItem,
clearDownloadHistory,
getDownloadHistory,
removeDownloadHistoryItem,
clearDownloadHistory,
} from "./downloadHistory";
// Video Download Tracking
export {
checkVideoDownloadBySourceId,
checkVideoDownloadByUrl,
recordVideoDownload,
handleVideoDownloadCheck,
markVideoDownloadDeleted,
recordVideoDownload,
updateVideoDownloadRecord,
verifyVideoExists,
handleVideoDownloadCheck,
} from "./videoDownloadTracking";
// Settings
@@ -40,31 +40,30 @@ export { getSettings, saveSettings } from "./settings";
// Videos
export {
getVideos,
getVideoBySourceUrl,
getVideoById,
deleteVideo,
formatLegacyFilenames,
getVideoById,
getVideoBySourceUrl,
getVideos,
saveVideo,
updateVideo,
deleteVideo,
} from "./videos";
// Collections
export {
getCollections,
getCollectionById,
getCollectionByVideoId,
getCollectionByName,
generateUniqueCollectionName,
saveCollection,
addVideoToCollection,
atomicUpdateCollection,
deleteCollection,
addVideoToCollection,
removeVideoFromCollection,
deleteCollectionWithFiles,
deleteCollectionAndVideos,
deleteCollectionWithFiles,
generateUniqueCollectionName,
getCollectionById,
getCollectionByName,
getCollectionByVideoId,
getCollections,
removeVideoFromCollection,
saveCollection,
} from "./collections";
// File Helpers
export { findVideoFile, findImageFile, moveFile } from "./fileHelpers";
export { findImageFile, findVideoFile, moveFile } from "./fileHelpers";

View File

@@ -8,9 +8,9 @@ import {
UPLOADS_DIR,
VIDEOS_DIR,
} from "../../config/paths";
import { MigrationError } from "../../errors/DownloadErrors";
import { db, sqlite } from "../../db";
import { downloads, videos } from "../../db/schema";
import { MigrationError } from "../../errors/DownloadErrors";
import { logger } from "../../utils/logger";
import { findVideoFile } from "./fileHelpers";
@@ -36,7 +36,10 @@ export function initializeStorage(): void {
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
logger.info("Cleared active downloads on startup");
} catch (error) {
logger.error("Error resetting active downloads", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error resetting active downloads",
error instanceof Error ? error : new Error(String(error))
);
fs.writeFileSync(
STATUS_DATA_PATH,
JSON.stringify({ activeDownloads: [], queuedDownloads: [] }, null, 2)
@@ -49,7 +52,10 @@ export function initializeStorage(): void {
db.delete(downloads).where(eq(downloads.status, "active")).run();
logger.info("Cleared active downloads from database on startup");
} catch (error) {
logger.error("Error clearing active downloads from database", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error clearing active downloads from database",
error instanceof Error ? error : new Error(String(error))
);
}
// Check and migrate tags column if needed
@@ -65,7 +71,10 @@ export function initializeStorage(): void {
logger.info("Migration successful.");
}
} catch (error) {
logger.error("Error checking/migrating tags column", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error checking/migrating tags column",
error instanceof Error ? error : new Error(String(error))
);
throw new MigrationError(
"Failed to migrate tags column",
"tags_column",
@@ -198,7 +207,10 @@ export function initializeStorage(): void {
.run();
} catch (indexError) {
// Indexes might already exist, ignore error
logger.debug("Index creation skipped (may already exist)", indexError instanceof Error ? indexError : new Error(String(indexError)));
logger.debug(
"Index creation skipped (may already exist)",
indexError instanceof Error ? indexError : new Error(String(indexError))
);
}
// Check download_history table for video_id, downloaded_at, deleted_at columns
@@ -276,13 +288,16 @@ export function initializeStorage(): void {
`
)
.run();
if (result.changes > 0) {
if (result && result.changes > 0) {
logger.info(
`Backfilled video_id for ${result.changes} download history items.`
);
}
} catch (error) {
logger.error("Error backfilling video_id in download history", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error backfilling video_id in download history",
error instanceof Error ? error : new Error(String(error))
);
}
} catch (error) {
logger.error(

View File

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

View File

@@ -1,6 +1,8 @@
export interface Settings {
loginEnabled: boolean;
password?: string;
passwordLoginAllowed?: boolean;
allowResetPassword?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;
@@ -21,7 +23,8 @@ export interface Settings {
proxyOnlyYoutube?: boolean;
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
visitorMode?: boolean;
visitorPassword?: string;
visitorUserEnabled?: boolean;
infiniteScroll?: boolean;
videoColumns?: number;
cloudflaredTunnelEnabled?: boolean;
@@ -47,10 +50,7 @@ export const defaultSettings: Settings = {
websiteName: "MyTube",
itemsPerPage: 12,
showYoutubeSearch: true,
visitorMode: false,
infiniteScroll: false,
videoColumns: 4,
pauseOnFocusLoss: false,
};

View File

@@ -58,8 +58,12 @@ export function validatePathWithinDirectory(
// Reconstruct paths from validated components only
// This ensures no path traversal sequences can exist
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
const sanitizedFilePath = path.isAbsolute(filePath)
? path.sep + path.join(...sanitizedFilePathParts)
: path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
? path.sep + path.join(...sanitizedAllowedDirParts)
: path.join(...sanitizedAllowedDirParts);
// Final validation: ensure reconstructed paths don't contain traversal sequences
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
@@ -130,8 +134,12 @@ export function resolveSafePath(filePath: string, allowedDir: string): string {
// Reconstruct paths from validated components only
// This ensures no path traversal sequences can exist
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
const sanitizedFilePath = path.isAbsolute(filePath)
? path.sep + path.join(...sanitizedFilePathParts)
: path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
? path.sep + path.join(...sanitizedAllowedDirParts)
: path.join(...sanitizedAllowedDirParts);
// Final validation: ensure reconstructed paths don't contain traversal sequences
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {

View File

@@ -1,17 +1,18 @@
{
"name": "frontend",
"version": "1.7.21",
"version": "1.7.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.7.21",
"version": "1.7.30",
"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.30",
"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';
@@ -45,14 +47,40 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
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");
});
});
});

View File

@@ -12,6 +12,7 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5551/api';
const apiClient: AxiosInstance = axios.create({
baseURL: API_URL,
timeout: 30000, // 30 seconds default timeout
withCredentials: true, // Required for HTTP-only cookies
headers: {
'Content-Type': 'application/json',
},
@@ -19,10 +20,12 @@ const apiClient: AxiosInstance = axios.create({
/**
* Request interceptor - can be used for adding auth tokens, logging, etc.
* Note: Authentication is now handled via HTTP-only cookies, so no Authorization header is needed
*/
apiClient.interceptors.request.use(
(config) => {
// Add any request modifications here (e.g., auth tokens)
// Cookies are automatically sent with requests when withCredentials: true
// No need to manually add Authorization header
return config;
},
(error) => {
@@ -187,7 +190,7 @@ export { apiClient };
/**
* Export API_URL for cases where it's needed directly
*/
export { API_URL };
export { API_URL };
export default api;

View File

@@ -81,6 +81,106 @@ export const formatDate = (dateString?: string) => {
return `${year}-${month}-${day}`;
};
/**
* Format relative time from download timestamp to current time
* 0 - 1 hour: "Just now"
* 1 hour - 5 hours: "X hours ago"
* 5 hours - 24 hours: "Today"
* 1 day - 7 days: "This week"
* 1 week - 4 weeks: "X weeks ago"
* > 4 weeks: show actual date
*/
export const formatRelativeDownloadTime = (
downloadTimestamp?: string,
originalDate?: string,
t?: (key: string, replacements?: Record<string, string | number>) => string
): string => {
const getTranslation = (
key: string,
replacements?: Record<string, string | number>
): string => {
if (t) {
return t(key as any, replacements);
}
// Fallback to English if no translation function provided
const fallbacks: Record<string, string> = {
justNow: "Just now",
hoursAgo: "{hours} hours ago",
today: "Today",
thisWeek: "This week",
weeksAgo: "{weeks} weeks ago",
unknownDate: "Unknown date",
};
let text = fallbacks[key] || key;
if (replacements) {
Object.entries(replacements).forEach(([placeholder, value]) => {
text = text.replace(`{${placeholder}}`, String(value));
});
}
return text;
};
if (!downloadTimestamp) {
// Fallback to original date format if no download timestamp
return originalDate
? formatDate(originalDate)
: getTranslation("unknownDate");
}
const downloadDate = new Date(downloadTimestamp);
const now = new Date();
// Check if date is valid
if (isNaN(downloadDate.getTime())) {
return originalDate
? formatDate(originalDate)
: getTranslation("unknownDate");
}
const diffMs = now.getTime() - downloadDate.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
const diffDays = diffMs / (1000 * 60 * 60 * 24);
const diffWeeks = diffDays / 7;
// 0 - 1 hour: "Just now"
if (diffHours < 1) {
return getTranslation("justNow");
}
// 1 hour - 5 hours: "X hours ago"
if (diffHours >= 1 && diffHours < 5) {
const hours = Math.floor(diffHours);
return getTranslation("hoursAgo", { hours });
}
// 5 hours - 24 hours: "Today"
if (diffHours >= 5 && diffHours < 24) {
return getTranslation("today");
}
// 1 day - 7 days: "This week"
if (diffDays >= 1 && diffDays < 7) {
return getTranslation("thisWeek");
}
// 1 week - 4 weeks: "X周前" / "X weeks ago"
if (diffWeeks >= 1 && diffWeeks < 4) {
const weeks = Math.floor(diffWeeks);
return getTranslation("weeksAgo", { weeks });
}
// > 4 weeks: show actual date
if (originalDate) {
return formatDate(originalDate);
}
// Format download date as YYYY-MM-DD if no original date
// Use UTC methods to ensure timezone independence
const year = downloadDate.getUTCFullYear();
const month = String(downloadDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(downloadDate.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
/**
* Generate timestamp string in format YYYY-MM-DD-HH-MM-SS
* Matches the backend generateTimestamp() function format
@@ -101,14 +201,17 @@ export const generateTimestamp = (): string => {
* If path is already a full URL (starts with http:// or https://), return it as is
* Otherwise, prepend BACKEND_URL
*/
export const getFileUrl = (path: string | null | undefined, backendUrl: string): string | undefined => {
export const getFileUrl = (
path: string | null | undefined,
backendUrl: string
): string | undefined => {
if (!path) return undefined;
// Check if path is already a full URL
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
// Otherwise, prepend backend URL
return `${backendUrl}${path}`;
};

View File

@@ -52,6 +52,12 @@ export const ar = {
videoColumns: "أعمدة الفيديو (الصفحة الرئيسية)",
columnsCount: "{count} أعمدة",
enableLogin: "تفعيل حماية تسجيل الدخول",
allowPasswordLogin: "السماح بتسجيل الدخول بكلمة المرور",
allowPasswordLoginHelper:
"عند التعطيل، لن يكون تسجيل الدخول بكلمة المرور متاحًا. يجب أن يكون لديك مفتاح وصول واحد على الأقل لتعطيل تسجيل الدخول بكلمة المرور.",
allowResetPassword: "السماح بإعادة تعيين كلمة المرور",
allowResetPasswordHelper:
"عند التعطيل، لن يتم عرض زر إعادة تعيين كلمة المرور في صفحة تسجيل الدخول وستتم حظر واجهة برمجة تطبيقات إعادة تعيين كلمة المرور.",
password: "كلمة المرور",
enterPassword: "أدخل كلمة المرور",
togglePasswordVisibility: "تبديل رؤية كلمة المرور",
@@ -127,10 +133,7 @@ export const ar = {
itemsPerPage: "عناصر لكل صفحة",
itemsPerPageHelper: "عدد مقاطع الفيديو المعروضة في كل صفحة (الافتراضي: 12)",
showYoutubeSearch: "عرض نتائج بحث YouTube",
visitorMode: "وضع الزائر (للقراءة فقط)",
visitorModeReadOnly: "وضع الزائر: للقراءة فقط",
visitorModeDescription: "وضع القراءة فقط. لن تكون مقاطع الفيديو المخفية مرئية للزوار.",
visitorModePasswordPrompt: "يرجى إدخال كلمة مرور الموقع لتغيير إعدادات وضع الزائر.",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
@@ -159,11 +162,13 @@ export const ar = {
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
token: "الرمز المميز (Token)",
publicUrl: "عنوان URL العام",
publicUrlHelper: "النطاق العام للوصول إلى الملفات (مثال: https://your-cloudflare-tunnel-domain.com). إذا تم تعيينه، سيتم استخدامه بدلاً من عنوان API للوصول إلى الملفات.",
publicUrlHelper:
"النطاق العام للوصول إلى الملفات (مثال: https://your-cloudflare-tunnel-domain.com). إذا تم تعيينه، سيتم استخدامه بدلاً من عنوان API للوصول إلى الملفات.",
uploadPath: "مسار التحميل",
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
scanPaths: "مسارات المسح",
scanPathsHelper: "مسار واحد في كل سطر. سيتم مسح مقاطع الفيديو من هذه المسارات. إذا كانت فارغة، سيتم استخدام مسار التحميل. مثال:\n/a/أفلام\n/b/وثائقيات",
scanPathsHelper:
"مسار واحد في كل سطر. سيتم مسح مقاطع الفيديو من هذه المسارات. إذا كانت فارغة، سيتم استخدام مسار التحميل. مثال:\n/a/أفلام\n/b/وثائقيات",
cloudDriveNote:
"بعد تفعيل هذه الميزة، سيتم تحميل مقاطع الفيديو التي تم تنزيلها حديثًا تلقائيًا إلى التخزين السحابي وسيتم حذف الملفات المحلية. سيتم تشغيل مقاطع الفيديو من التخزين السحابي عبر الوكيل.",
cloudScanAdded: "تمت الإضافة من السحابة",
@@ -171,7 +176,8 @@ export const ar = {
testConnection: "اختبار الاتصال",
sync: "مزامنة",
syncToCloud: "مزامنة ثنائية الاتجاه",
syncWarning: "ستقوم هذه العملية برفع مقاطع الفيديو المحلية إلى السحابة ومسح التخزين السحابي بحثًا عن ملفات جديدة. سيتم حذف الملفات المحلية بعد الرفع.",
syncWarning:
"ستقوم هذه العملية برفع مقاطع الفيديو المحلية إلى السحابة ومسح التخزين السحابي بحثًا عن ملفات جديدة. سيتم حذف الملفات المحلية بعد الرفع.",
syncing: "جاري المزامنة...",
syncCompleted: "اكتملت المزامنة",
syncFailed: "فشلت المزامنة",
@@ -188,9 +194,11 @@ export const ar = {
uploadingVideo: "جاري الرفع: {title}",
clearThumbnailCache: "مسح ذاكرة التخزين المؤقت للصور المصغرة",
clearing: "جاري المسح...",
clearThumbnailCacheSuccess: "تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
clearThumbnailCacheSuccess:
"تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
clearThumbnailCacheError: "فشل مسح ذاكرة التخزين المؤقت للصور المصغرة",
clearThumbnailCacheConfirmMessage: "سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
clearThumbnailCacheConfirmMessage:
"سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
// Manage
manageContent: "إدارة المحتوى",
@@ -280,7 +288,8 @@ export const ar = {
openInExternalPlayer: "فتح في مشغل خارجي",
playWith: "تشغيل بواسطة...",
deleteAllFilteredVideos: "حذف جميع الفيديوهات المصفاة",
confirmDeleteFilteredVideos: "هل أنت متأكد أنك تريد حذف {count} فيديو مصفى بواسطة العلامات المحددة؟",
confirmDeleteFilteredVideos:
"هل أنت متأكد أنك تريد حذف {count} فيديو مصفى بواسطة العلامات المحددة؟",
deleteFilteredVideosSuccess: "تم حذف {count} فيديو بنجاح.",
deletingVideos: "جاري حذف الفيديوهات...",
@@ -302,10 +311,35 @@ export const ar = {
resetPasswordConfirm: "إعادة التعيين",
resetPasswordSuccess:
"تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
resetPasswordDisabledInfo:
"تم تعطيل إعادة تعيين كلمة المرور. لإعادة تعيين كلمة المرور، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nسيؤدي هذا إلى إنشاء كلمة مرور عشوائية جديدة وتمكين تسجيل الدخول بكلمة المرور.",
resetPasswordScriptGuide:
"لإعادة تعيين كلمة المرور يدوياً، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nإذا لم يتم توفير كلمة مرور، سيتم إنشاء كلمة مرور عشوائية مكونة من 8 أحرف.",
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
// Passkeys
createPasskey: "إنشاء مفتاح مرور",
creatingPasskey: "جاري الإنشاء...",
passkeyCreated: "تم إنشاء مفتاح المرور بنجاح",
passkeyCreationFailed: "فشل إنشاء مفتاح المرور. يرجى المحاولة مرة أخرى.",
removePasskeys: "إزالة جميع مفاتيح المرور",
removePasskeysTitle: "إزالة جميع مفاتيح المرور",
removePasskeysMessage:
"هل أنت متأكد من أنك تريد إزالة جميع مفاتيح المرور؟ لا يمكن التراجع عن هذا الإجراء.",
passkeysRemoved: "تم إزالة جميع مفاتيح المرور",
passkeysRemoveFailed: "فشل إزالة مفاتيح المرور. يرجى المحاولة مرة أخرى.",
loginWithPasskey: "تسجيل الدخول بمفتاح المرور",
authenticating: "جاري المصادقة...",
passkeyLoginFailed: "فشلت مصادقة مفتاح المرور. يرجى المحاولة مرة أخرى.",
passkeyErrorPermissionDenied:
"لا يُسمح بالطلب من قبل وكيل المستخدم أو المنصة في السياق الحالي، ربما لأن المستخدم رفض الإذن.",
passkeyErrorAlreadyRegistered: "تم تسجيل المصادق مسبقاً.",
linkCopied: "تم نسخ الرابط إلى الحافظة",
copyFailed: "فشل نسخ الرابط",
passkeyRequiresHttps:
"يتطلب WebAuthn استخدام HTTPS أو localhost. يرجى الدخول إلى التطبيق عبر HTTPS أو استخدام localhost بدلاً من عنوان IP.",
passkeyWebAuthnNotSupported:
"WebAuthn غير مدعوم في هذا المتصفح. يرجى استخدام متصفح حديث يدعم WebAuthn.",
// Collection Page
loadingCollection: "جاري تحميل المجموعة...",
@@ -366,6 +400,11 @@ export const ar = {
unknownDate: "تاريخ غير معروف",
part: "جزء",
collection: "مجموعة",
justNow: "الآن",
hoursAgo: "منذ {hours} ساعة",
today: "اليوم",
thisWeek: "هذا الأسبوع",
weeksAgo: "منذ {weeks} أسبوع",
// Upload Modal
selectVideoFile: "اختر ملف فيديو",
@@ -382,7 +421,8 @@ export const ar = {
authorOrPlaylist: "المؤلف / قائمة التشغيل",
playlistDetected: "تم اكتشاف قائمة تشغيل",
playlistHasVideos: "تحتوي قائمة التشغيل هذه على {count} فيديوهات.",
downloadPlaylistAndCreateCollection: "هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
downloadPlaylistAndCreateCollection:
"هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
collectionHasVideos: "تحتوي هذه المجموعة من Bilibili على {count} فيديوهات.",
seriesHasVideos: "تحتوي هذه السلسلة من Bilibili على {count} فيديوهات.",
videoHasParts: "يحتوي هذا الفيديو من Bilibili على {count} أجزاء.",
@@ -458,11 +498,13 @@ export const ar = {
confirmCancelTask: "هل أنت متأكد أنك تريد إلغاء مهمة التنزيل لـ {author}؟",
taskCancelled: "تم إلغاء المهمة بنجاح",
deleteTask: "حذف المهمة",
confirmDeleteTask: "هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
confirmDeleteTask:
"هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
taskDeleted: "تم حذف المهمة بنجاح",
clearFinishedTasks: "مسح المهام المنتهية",
tasksCleared: "تم مسح المهام المنتهية بنجاح",
confirmClearFinishedTasks: "هل أنت متأكد أنك تريد مسح جميع المهام المنتهية (المكتملة، الملغاة)؟ سيؤدي هذا إلى إزالتها من القائمة ولكن لن يحذف أي ملفات تم تنزيلها.",
confirmClearFinishedTasks:
"هل أنت متأكد أنك تريد مسح جميع المهام المنتهية (المكتملة، الملغاة)؟ سيؤدي هذا إلى إزالتها من القائمة ولكن لن يحذف أي ملفات تم تنزيلها.",
clear: "مسح",
// Instruction Page
instructionSection1Title: "1. التنزيل وإدارة المهام",
@@ -586,7 +628,8 @@ export const ar = {
lastBackupDate: "تاريخ آخر نسخة احتياطية",
noBackupAvailable: "لا توجد نسخة احتياطية متاحة",
deleteAuthor: "حذف المؤلف",
deleteAuthorConfirmation: "هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
deleteAuthorConfirmation:
"هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
authorDeletedSuccessfully: "تم حذف المؤلف بنجاح",
failedToDeleteAuthor: "فشل حذف المؤلف",
@@ -594,7 +637,8 @@ export const ar = {
cloudflaredTunnel: "نفق Cloudflare",
enableCloudflaredTunnel: "تمكين نفق Cloudflare",
cloudflaredToken: "رمز النفق (اختياري)",
cloudflaredTokenHelper: "الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
cloudflaredTokenHelper:
"الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
waitingForUrl: "في انتظار عنوان النفق السريع URL...",
running: "يعمل",
stopped: "متوقف",
@@ -602,8 +646,10 @@ export const ar = {
accountTag: "علامة الحساب",
copied: "تم النسخ!",
clickToCopy: "انقر للنسخ",
quickTunnelWarning: "تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
managedInDashboard: "تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
quickTunnelWarning:
"تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
managedInDashboard:
"تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
failedToDownloadVideo: "فشل تنزيل الفيديو. يرجى المحاولة مرة أخرى.",
failedToDownload: "فشل التنزيل. يرجى المحاولة مرة أخرى.",
playlistDownloadStarted: "بدأ تنزيل قائمة التشغيل",
@@ -612,24 +658,39 @@ export const ar = {
copyUrl: "نسخ الرابط",
new: "جديد",
// Task Hooks
taskHooks: 'خطافات المهام',
taskHooksDescription: 'نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.',
hookTaskBeforeStart: 'قبل بدء المهمة',
hookTaskBeforeStartHelper: 'ينفذ قبل بدء التنزيل.',
hookTaskSuccess: 'نجاح المهمة',
hookTaskSuccessHelper: 'ينفذ بعد التنزيل الناجح، قبل الرفع السحابي/الحذف (ينتظر الاكتمال).',
hookTaskFail: 'فشل المهمة',
hookTaskFailHelper: 'ينفذ عند فشل المهمة.',
hookTaskCancel: 'إلغاء المهمة',
hookTaskCancelHelper: 'ينفذ عند إلغاء المهمة يدوياً.',
found: 'موجود',
notFound: 'غير معين',
deleteHook: 'حذف سكريبت الخطاف',
confirmDeleteHook: 'هل أنت متأكد أنك تريد حذف سكريبت الخطاف هذا؟',
uploadHook: 'رفع .sh',
taskHooks: "خطافات المهام",
taskHooksDescription:
"نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning: "تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.",
enterPasswordToUploadHook: "الرجاء إدخال كلمة المرور لتحميل نص Hook هذا.",
riskCommandDetected: "تم اكتشاف أمر خطر: {command}. تم رفض التحميل.",
hookTaskBeforeStart: "قبل بدء المهمة",
hookTaskBeforeStartHelper: "ينفذ قبل بدء التنزيل.",
hookTaskSuccess: "نجاح المهمة",
hookTaskSuccessHelper:
"ينفذ بعد التنزيل الناجح، قبل الرفع السحابي/الحذف (ينتظر الاكتمال).",
hookTaskFail: "فشل المهمة",
hookTaskFailHelper: "ينفذ عند فشل المهمة.",
hookTaskCancel: "إلغاء المهمة",
hookTaskCancelHelper: "ينفذ عند إلغاء المهمة يدوياً.",
found: "موجود",
notFound: "غير معين",
deleteHook: "حذف سكريبت الخطاف",
confirmDeleteHook: "هل أنت متأكد أنك تريد حذف سكريبت الخطاف هذا؟",
uploadHook: "رفع .sh",
disclaimerTitle: "إخلاء المسؤولية",
disclaimerText: "1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
disclaimerText:
"1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
// Visitor Mode
admin: "مشرف",
visitorSignIn: "تسجيل دخول الزائر",
visitorUser: "المستخدم الزائر",
enableVisitorUser: "تفعيل المستخدم الزائر",
visitorUserHelper:
"قم تمكين حساب زائر منفصل مع وصول للقراءة فقط. يمكن للزوار عرض المحتوى ولكن لا يمكنهم إجراء تغييرات.",
visitorPassword: "كلمة مرور الزائر",
visitorPasswordHelper: "تعيين كلمة المرور لحساب الزائر.",
visitorPasswordSetHelper:
"تم تعيين كلمة المرور. اتركها فارغة للاحتفاظ بها.",
};

View File

@@ -45,11 +45,18 @@ export const de = {
websiteName: "Website-Name",
websiteNameHelper: "{current}/{max} Zeichen (Standard: {default})",
infiniteScroll: "Unendliches Scrollen",
infiniteScrollDisabled: "Deaktiviert, wenn unendliches Scrollen aktiviert ist",
infiniteScrollDisabled:
"Deaktiviert, wenn unendliches Scrollen aktiviert ist",
maxVideoColumns: "Maximale Videospalten (Startseite)",
videoColumns: "Videospalten (Startseite)",
columnsCount: "{count} Spalten",
enableLogin: "Anmeldeschutz aktivieren",
allowPasswordLogin: "Passwort-Anmeldung zulassen",
allowPasswordLoginHelper:
"Wenn deaktiviert, ist die Passwort-Anmeldung nicht verfügbar. Sie müssen mindestens einen Passkey haben, um die Passwort-Anmeldung zu deaktivieren.",
allowResetPassword: "Passwort zurücksetzen zulassen",
allowResetPasswordHelper:
"Wenn deaktiviert, wird die Schaltfläche zum Zurücksetzen des Passworts auf der Anmeldeseite nicht angezeigt und die API zum Zurücksetzen des Passworts wird blockiert.",
password: "Passwort",
enterPassword: "Passwort eingeben",
togglePasswordVisibility: "Passwort sichtbar machen",
@@ -92,7 +99,6 @@ export const de = {
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.",
migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
migrationFailed: "Migration fehlgeschlagen",
migrationWarnings: "WARNUNGEN",
migrationErrors: "FEHLER",
itemsMigrated: "Elemente migriert",
@@ -123,10 +129,7 @@ export const de = {
itemsPerPage: "Elemente pro Seite",
itemsPerPageHelper: "Anzahl der Videos pro Seite (Standard: 12)",
showYoutubeSearch: "YouTube-Suchergebnisse anzeigen",
visitorMode: "Besuchermodus (Nur-Lesen)",
visitorModeReadOnly: "Besuchermodus: Nur-Lesen",
visitorModeDescription: "Nur-Lese-Modus. Ausgeblendete Videos sind für Besucher nicht sichtbar.",
visitorModePasswordPrompt: "Bitte geben Sie das Website-Passwort ein, um die Besuchermodus-Einstellungen zu ändern.",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
@@ -155,12 +158,14 @@ export const de = {
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "Öffentliche URL",
publicUrlHelper: "Öffentliche Domain für den Dateizugriff (z.B. https://your-cloudflare-tunnel-domain.com). Wenn gesetzt, wird diese anstelle der API-URL für den Dateizugriff verwendet.",
publicUrlHelper:
"Öffentliche Domain für den Dateizugriff (z.B. https://your-cloudflare-tunnel-domain.com). Wenn gesetzt, wird diese anstelle der API-URL für den Dateizugriff verwendet.",
uploadPath: "Upload-Pfad",
cloudDrivePathHelper:
"Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
scanPaths: "Scan-Pfade",
scanPathsHelper: "Ein Pfad pro Zeile. Videos werden von diesen Pfaden gescannt. Wenn leer, wird der Upload-Pfad verwendet. Beispiel:\n/a/Filme\n/b/Dokumentationen",
scanPathsHelper:
"Ein Pfad pro Zeile. Videos werden von diesen Pfaden gescannt. Wenn leer, wird der Upload-Pfad verwendet. Beispiel:\n/a/Filme\n/b/Dokumentationen",
cloudDriveNote:
"Nach Aktivierung dieser Funktion werden neu heruntergeladene Videos automatisch in den Cloud-Speicher hochgeladen und lokale Dateien werden gelöscht. Videos werden über einen Proxy aus dem Cloud-Speicher abgespielt.",
cloudScanAdded: "Aus Cloud hinzugefügt",
@@ -168,26 +173,36 @@ export const de = {
testConnection: "Verbindung testen",
sync: "Synchronisieren",
syncToCloud: "Zwei-Wege-Synchronisierung",
syncWarning: "Dieser Vorgang lädt lokale Videos in die Cloud hoch und sucht im Cloud-Speicher nach neuen Dateien. Lokale Dateien werden nach dem Upload gelöscht.",
syncWarning:
"Dieser Vorgang lädt lokale Videos in die Cloud hoch und sucht im Cloud-Speicher nach neuen Dateien. Lokale Dateien werden nach dem Upload gelöscht.",
syncing: "Synchronisiere...",
syncCompleted: "Synchronisation abgeschlossen",
syncFailed: "Synchronisation fehlgeschlagen",
syncReport: "Gesamt: {total} | Hochgeladen: {uploaded} | Fehlgeschlagen: {failed}",
syncReport:
"Gesamt: {total} | Hochgeladen: {uploaded} | Fehlgeschlagen: {failed}",
syncErrors: "Fehler:",
fillApiUrlToken: "Bitte füllen Sie zuerst API-URL und Token aus",
connectionTestSuccess: "Verbindungstest erfolgreich! Einstellungen sind gültig.",
connectionFailedStatus: "Verbindung fehlgeschlagen: Server gab Status {status} zurück",
connectionFailedUrl: "Kann nicht mit Server verbinden. Bitte überprüfen Sie die API-URL.",
authFailed: "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihr Token.",
connectionTestSuccess:
"Verbindungstest erfolgreich! Einstellungen sind gültig.",
connectionFailedStatus:
"Verbindung fehlgeschlagen: Server gab Status {status} zurück",
connectionFailedUrl:
"Kann nicht mit Server verbinden. Bitte überprüfen Sie die API-URL.",
authFailed:
"Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihr Token.",
connectionTestFailed: "Verbindungstest fehlgeschlagen: {error}",
syncFailedMessage: "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
foundVideosToSync: "{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
syncFailedMessage:
"Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
foundVideosToSync:
"{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
uploadingVideo: "Lade hoch: {title}",
clearThumbnailCache: "Lokalen Thumbnail-Cache leeren",
clearing: "Leeren...",
clearThumbnailCacheSuccess: "Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
clearThumbnailCacheSuccess:
"Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
clearThumbnailCacheError: "Fehler beim Leeren des Thumbnail-Caches",
clearThumbnailCacheConfirmMessage: "Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
clearThumbnailCacheConfirmMessage:
"Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
manageContent: "Inhalte Verwalten",
videos: "Videos",
@@ -275,7 +290,8 @@ export const de = {
openInExternalPlayer: "In externem Player öffnen",
playWith: "Abspielen mit...",
deleteAllFilteredVideos: "Alle gefilterten Videos löschen",
confirmDeleteFilteredVideos: "Sind Sie sicher, dass Sie {count} Videos löschen möchten, die nach den ausgewählten Tags gefiltert wurden?",
confirmDeleteFilteredVideos:
"Sind Sie sicher, dass Sie {count} Videos löschen möchten, die nach den ausgewählten Tags gefiltert wurden?",
deleteFilteredVideosSuccess: "Erfolgreich {count} Videos gelöscht.",
deletingVideos: "Videos werden gelöscht...",
signIn: "Anmelden",
@@ -295,10 +311,39 @@ export const de = {
resetPasswordConfirm: "Zurücksetzen",
resetPasswordSuccess:
"Das Passwort wurde zurückgesetzt. Überprüfen Sie die Backend-Protokolle für das neue Passwort.",
resetPasswordDisabledInfo:
"Die Passwort-Zurücksetzung ist deaktiviert. Um Ihr Passwort zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nDies generiert ein neues zufälliges Passwort und aktiviert die Passwort-Anmeldung.",
resetPasswordScriptGuide:
"Um das Passwort manuell zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nWenn kein Passwort angegeben wird, wird ein zufälliges 8-stelliges Passwort generiert.",
waitTimeMessage: "Bitte warten Sie {time}, bevor Sie es erneut versuchen.",
tooManyAttempts: "Zu viele fehlgeschlagene Versuche.",
// Passkeys
createPasskey: "Passkey erstellen",
creatingPasskey: "Wird erstellt...",
passkeyCreated: "Passkey erfolgreich erstellt",
passkeyCreationFailed:
"Passkey konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
removePasskeys: "Alle Passkeys entfernen",
removePasskeysTitle: "Alle Passkeys entfernen",
removePasskeysMessage:
"Sind Sie sicher, dass Sie alle Passkeys entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
passkeysRemoved: "Alle Passkeys wurden entfernt",
passkeysRemoveFailed:
"Passkeys konnten nicht entfernt werden. Bitte versuchen Sie es erneut.",
loginWithPasskey: "Mit Passkey anmelden",
authenticating: "Wird authentifiziert...",
passkeyLoginFailed:
"Passkey-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
passkeyErrorPermissionDenied:
"Die Anfrage wird vom Benutzer-Agenten oder der Plattform im aktuellen Kontext nicht zugelassen, möglicherweise weil der Benutzer die Berechtigung verweigert hat.",
passkeyErrorAlreadyRegistered:
"Der Authentifikator wurde bereits zuvor registriert.",
linkCopied: "Link in die Zwischenablage kopiert",
copyFailed: "Link konnte nicht kopiert werden",
passkeyRequiresHttps:
"WebAuthn erfordert HTTPS oder localhost. Bitte greifen Sie über HTTPS auf die Anwendung zu oder verwenden Sie localhost anstelle einer IP-Adresse.",
passkeyWebAuthnNotSupported:
"WebAuthn wird in diesem Browser nicht unterstützt. Bitte verwenden Sie einen modernen Browser, der WebAuthn unterstützt.",
loadingCollection: "Sammlung wird geladen...",
collectionNotFound: "Sammlung nicht gefunden",
noVideosInCollection: "Keine Videos in dieser Sammlung.",
@@ -308,7 +353,8 @@ export const de = {
unknownAuthor: "Unbekannt",
noVideosForAuthor: "Keine Videos für diesen Autor gefunden.",
deleteAuthor: "Autor löschen",
deleteAuthorConfirmation: "Sind Sie sicher, dass Sie den Autor {author} löschen möchten? Dies löscht alle Videos dieses Autors.",
deleteAuthorConfirmation:
"Sind Sie sicher, dass Sie den Autor {author} löschen möchten? Dies löscht alle Videos dieses Autors.",
authorDeletedSuccessfully: "Autor erfolgreich gelöscht",
failedToDeleteAuthor: "Fehler beim Löschen des Autors",
deleteCollectionTitle: "Sammlung Löschen",
@@ -334,6 +380,11 @@ export const de = {
unknownDate: "Unbekanntes Datum",
part: "Teil",
collection: "Sammlung",
justNow: "Gerade eben",
hoursAgo: "vor {hours} Stunden",
today: "Heute",
thisWeek: "Diese Woche",
weeksAgo: "vor {weeks} Wochen",
selectVideoFile: "Videodatei Auswählen",
pleaseSelectVideo: "Bitte wählen Sie eine Videodatei aus",
uploadFailed: "Upload fehlgeschlagen",
@@ -346,7 +397,8 @@ export const de = {
authorOrPlaylist: "Autor / Wiedergabeliste",
playlistDetected: "Wiedergabeliste erkannt",
playlistHasVideos: "Diese Wiedergabeliste hat {count} Videos.",
downloadPlaylistAndCreateCollection: "Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
downloadPlaylistAndCreateCollection:
"Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
collectionHasVideos: "Diese Bilibili-Sammlung hat {count} Videos.",
seriesHasVideos: "Diese Bilibili-Serie hat {count} Videos.",
videoHasParts: "Dieses Bilibili-Video hat {count} Teile.",
@@ -418,7 +470,8 @@ export const de = {
subscriptionAlreadyExists: "Sie haben diesen Autor bereits abonniert.",
minutes: "Minuten",
never: "Nie",
downloadAllPreviousVideos: "Alle vorherigen Videos dieses Autors herunterladen",
downloadAllPreviousVideos:
"Alle vorherigen Videos dieses Autors herunterladen",
downloadAllPreviousWarning:
"Warnung: Dies lädt alle vorherigen Videos dieses Autors herunter. Dies kann erheblichen Speicherplatz verbrauchen und könnte Bot-Erkennungsmechanismen auslösen, die zu temporären oder dauerhaften Sperren der Plattform führen können. Verwenden Sie auf eigenes Risiko.",
continuousDownloadTasks: "Kontinuierliche Download-Aufgaben",
@@ -428,14 +481,17 @@ export const de = {
taskStatusCancelled: "Abgebrochen",
downloaded: "Heruntergeladen",
cancelTask: "Aufgabe abbrechen",
confirmCancelTask: "Sind Sie sicher, dass Sie die Download-Aufgabe für {author} abbrechen möchten?",
confirmCancelTask:
"Sind Sie sicher, dass Sie die Download-Aufgabe für {author} abbrechen möchten?",
taskCancelled: "Aufgabe erfolgreich abgebrochen",
deleteTask: "Aufgabe löschen",
confirmDeleteTask: "Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
confirmDeleteTask:
"Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
taskDeleted: "Aufgabe erfolgreich gelöscht",
clearFinishedTasks: "Beendete Aufgaben löschen",
tasksCleared: "Beendete Aufgaben erfolgreich gelöscht",
confirmClearFinishedTasks: "Sind Sie sicher, dass Sie alle beendeten Aufgaben (abgeschlossen, abgebrochen) löschen möchten? Dies entfernt sie aus der Liste, löscht aber keine heruntergeladenen Dateien.",
confirmClearFinishedTasks:
"Sind Sie sicher, dass Sie alle beendeten Aufgaben (abgeschlossen, abgebrochen) löschen möchten? Dies entfernt sie aus der Liste, löscht aber keine heruntergeladenen Dateien.",
clear: "Löschen",
// Instruction Page
instructionSection1Title: "1. Download & Aufgabenverwaltung",
@@ -575,7 +631,8 @@ export const de = {
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Cloudflare Tunnel aktivieren",
cloudflaredToken: "Tunnel-Token (Optional)",
cloudflaredTokenHelper: "Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
cloudflaredTokenHelper:
"Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
waitingForUrl: "Warte auf Quick Tunnel URL...",
running: "Läuft",
stopped: "Gestoppt",
@@ -583,36 +640,60 @@ export const de = {
accountTag: "Konto-Tag",
copied: "Kopiert!",
clickToCopy: "Zum Kopieren klicken",
quickTunnelWarning: "Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
managedInDashboard: "Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
failedToDownloadVideo: "Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
quickTunnelWarning:
"Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
managedInDashboard:
"Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
failedToDownloadVideo:
"Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
failedToDownload: "Fehler beim Herunterladen. Bitte versuchen Sie es erneut.",
playlistDownloadStarted: "Playlist-Download gestartet",
cleanupTempFilesConfirmMessage: "Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine Downloads aktiv sind, bevor Sie fortfahren.",
cleanupTempFilesActiveDownloads: "Temporäre Dateien können nicht bereinigt werden, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie zuerst ab.",
cleanupTempFilesConfirmMessage:
"Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine Downloads aktiv sind, bevor Sie fortfahren.",
cleanupTempFilesActiveDownloads:
"Temporäre Dateien können nicht bereinigt werden, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie zuerst ab.",
status: "Status",
videoDownloading: "Video wird heruntergeladen",
copyUrl: "URL kopieren",
new: "NEU",
// Task Hooks
taskHooks: 'Aufgaben-Hoks',
taskHooksDescription: 'Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.',
hookTaskBeforeStart: 'Vor Aufgabenstart',
hookTaskBeforeStartHelper: 'Wird ausgeführt, bevor der Download beginnt.',
hookTaskSuccess: 'Aufgabe Erfolgreich',
hookTaskSuccessHelper: 'Wird nach erfolgreichem Download ausgeführt, vor Cloud-Upload/Löschung (wartet auf Abschluss).',
hookTaskFail: 'Aufgabe Fehlgeschlagen',
hookTaskFailHelper: 'Wird ausgeführt, wenn eine Aufgabe fehlschlägt.',
hookTaskCancel: 'Aufgabe Abgebrochen',
hookTaskCancelHelper: 'Wird ausgeführt, wenn eine Aufgabe manuell abgebrochen wird.',
found: 'Gefunden',
notFound: 'Nicht Gesetzt',
deleteHook: 'Hook-Skript Löschen',
confirmDeleteHook: 'Sind Sie sicher, dass Sie dieses Hook-Skript löschen möchten?',
uploadHook: 'Hochladen .sh',
taskHooks: "Aufgaben-Hoks",
taskHooksDescription:
"Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.",
enterPasswordToUploadHook:
"Bitte geben Sie Ihr Passwort ein, um dieses Hook-Skript hochzuladen.",
riskCommandDetected: "Risikobefehl erkannt: {command}. Upload abgelehnt.",
hookTaskBeforeStart: "Vor Aufgabenstart",
hookTaskBeforeStartHelper: "Wird ausgeführt, bevor der Download beginnt.",
hookTaskSuccess: "Aufgabe Erfolgreich",
hookTaskSuccessHelper:
"Wird nach erfolgreichem Download ausgeführt, vor Cloud-Upload/Löschung (wartet auf Abschluss).",
hookTaskFail: "Aufgabe Fehlgeschlagen",
hookTaskFailHelper: "Wird ausgeführt, wenn eine Aufgabe fehlschlägt.",
hookTaskCancel: "Aufgabe Abgebrochen",
hookTaskCancelHelper:
"Wird ausgeführt, wenn eine Aufgabe manuell abgebrochen wird.",
found: "Gefunden",
notFound: "Nicht Gesetzt",
deleteHook: "Hook-Skript Löschen",
confirmDeleteHook:
"Sind Sie sicher, dass Sie dieses Hook-Skript löschen möchten?",
uploadHook: "Hochladen .sh",
disclaimerTitle: "Haftungsausschluss",
disclaimerText: "1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
disclaimerText:
"1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
// Visitor Mode
admin: "Admin",
visitorSignIn: "Besucher-Anmeldung",
visitorUser: "Besucher-Benutzer",
enableVisitorUser: "Besucher-Benutzer aktivieren",
visitorUserHelper:
"Aktivieren Sie ein separates Besucherkonto mit schreibgeschütztem Zugriff. Besucher können Inhalte ansehen, aber keine Änderungen vornehmen.",
visitorPassword: "Besucher-Passwort",
visitorPasswordHelper: "Legen Sie das Passwort für das Besucherkonto fest.",
visitorPasswordSetHelper:
"Passwort ist gesetzt. Leer lassen, um es zu behalten.",
};

View File

@@ -52,6 +52,12 @@ export const en = {
videoColumns: "Video Columns (Homepage)",
columnsCount: "{count} Columns",
enableLogin: "Enable Login Protection",
allowPasswordLogin: "Allow Password Login",
allowPasswordLoginHelper:
"When disabled, password login is not available. You must have at least one passkey to disable password login.",
allowResetPassword: "Allow Reset Password",
allowResetPasswordHelper:
"When disabled, the reset password button will not be shown on the login page and the reset password API will be blocked.",
password: "Password",
enterPassword: "Enter Password",
togglePasswordVisibility: "Toggle password visibility",
@@ -116,22 +122,28 @@ export const en = {
"This will permanently delete all .ytdl and .part files in the uploads directory. Make sure there are no active downloads before proceeding.",
// Task Hooks
taskHooks: 'Task Hooks',
taskHooksDescription: 'Execute custom shell commands at specific points in the task lifecycle. Available environment variables: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Warning: Commands run with the server\'s permissions. Use with caution.',
hookTaskBeforeStart: 'Before Task Start',
hookTaskBeforeStartHelper: 'Executes before the download begins.',
hookTaskSuccess: 'Task Success',
hookTaskSuccessHelper: 'Executes after successful download, before cloud upload/deletion (awaits completion).',
hookTaskFail: 'Task Failed',
hookTaskFailHelper: 'Executes when a task fails.',
hookTaskCancel: 'Task Cancelled',
hookTaskCancelHelper: 'Executes when a task is manually cancelled.',
found: 'Found',
notFound: 'Not Set',
deleteHook: 'Delete Hook Script',
confirmDeleteHook: 'Are you sure you want to delete this hook script?',
uploadHook: 'Upload .sh',
taskHooks: "Task Hooks",
taskHooksDescription:
"Execute custom shell commands at specific points in the task lifecycle. Available environment variables: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Warning: Commands run with the server's permissions. Use with caution.",
hookTaskBeforeStart: "Before Task Start",
hookTaskBeforeStartHelper: "Executes before the download begins.",
hookTaskSuccess: "Task Success",
hookTaskSuccessHelper:
"Executes after successful download, before cloud upload/deletion (awaits completion).",
hookTaskFail: "Task Failed",
hookTaskFailHelper: "Executes when a task fails.",
hookTaskCancel: "Task Cancelled",
hookTaskCancelHelper: "Executes when a task is manually cancelled.",
found: "Found",
notFound: "Not Set",
deleteHook: "Delete Hook Script",
confirmDeleteHook: "Are you sure you want to delete this hook script?",
uploadHook: "Upload .sh",
enterPasswordToUploadHook:
"Please enter your password to upload this hook script.",
riskCommandDetected: "Risk command detected: {command}. Upload rejected.",
cleanupTempFilesActiveDownloads:
"Cannot clean up temporary files while downloads are active. Please wait for all downloads to complete or cancel them first.",
formatFilenamesSuccess:
@@ -142,12 +154,14 @@ export const en = {
itemsPerPage: "Items Per Page",
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
showYoutubeSearch: "Show YouTube Search Results",
visitorMode: "Visitor Mode (Read-only)",
visitorModeReadOnly: "Visitor mode: Read-only",
visitorModeDescription:
"Read-only mode. Hidden videos will not be visible to visitors.",
visitorModePasswordPrompt:
"Please enter the website password to change Visitor Mode settings.",
visitorUser: "Visitor User",
enableVisitorUser: "Enable Visitor User",
visitorUserHelper:
"Enable a separate visitor account with read-only access. Visitors can view content but cannot make changes.",
visitorPassword: "Visitor Password",
visitorPasswordHelper: "Set the password for the visitor account.",
visitorPasswordSetHelper: "Password is set. Leave empty to keep it.",
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
cleanupTempFilesFailed: "Failed to clean up temporary files",
@@ -208,9 +222,11 @@ export const en = {
uploadingVideo: "Uploading: {title}",
clearThumbnailCache: "Clear Thumbnail Local Cache",
clearing: "Clearing...",
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
clearThumbnailCacheSuccess:
"Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
clearThumbnailCacheError: "Failed to clear thumbnail cache",
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
clearThumbnailCacheConfirmMessage:
"This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
// Manage
manageContent: "Manage Content",
@@ -306,6 +322,9 @@ export const en = {
// Login
signIn: "Sign in",
admin: "Admin",
visitorSignIn: "Visitor Sign In",
orVisitor: "OR VISITOR",
verifying: "Verifying...",
incorrectPassword: "Incorrect password",
loginFailed: "Failed to verify password",
@@ -322,8 +341,33 @@ export const en = {
resetPasswordConfirm: "Reset",
resetPasswordSuccess:
"Password has been reset. Check backend logs for the new password.",
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.",
resetPasswordScriptGuide:
"To reset password manually, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nIf no password is provided, a random 8-character password will be generated.",
waitTimeMessage: "Please wait {time} before trying again.",
tooManyAttempts: "Too many failed attempts.",
// Passkeys
createPasskey: "Create Passkey",
creatingPasskey: "Creating...",
passkeyCreated: "Passkey created successfully",
passkeyCreationFailed: "Failed to create passkey. Please try again.",
passkeyWebAuthnNotSupported:
"WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.",
passkeyRequiresHttps:
"WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.",
removePasskeys: "Remove All Passkeys",
removePasskeysTitle: "Remove All Passkeys",
removePasskeysMessage:
"Are you sure you want to remove all passkeys? This action cannot be undone.",
passkeysRemoved: "All passkeys have been removed",
passkeysRemoveFailed: "Failed to remove passkeys. Please try again.",
loginWithPasskey: "Login with Passkey",
authenticating: "Authenticating...",
passkeyLoginFailed: "Passkey authentication failed. Please try again.",
passkeyErrorPermissionDenied:
"The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.",
passkeyErrorAlreadyRegistered: "The authenticator was previously registered.",
linkCopied: "Link copied to clipboard",
copyFailed: "Failed to copy link",
copyUrl: "Copy URL",
@@ -373,6 +417,11 @@ export const en = {
part: "Part",
collection: "Collection",
new: "NEW",
justNow: "Just now",
hoursAgo: "{hours} hours ago",
today: "Today",
thisWeek: "This week",
weeksAgo: "{weeks} weeks ago",
// Upload Modal
selectVideoFile: "Select Video File",
@@ -389,7 +438,8 @@ export const en = {
authorOrPlaylist: "Author / Playlist",
playlistDetected: "Playlist Detected",
playlistHasVideos: "This playlist has {count} videos.",
downloadPlaylistAndCreateCollection: "Download playlist videos and create a Collection for it?",
downloadPlaylistAndCreateCollection:
"Download playlist videos and create a Collection for it?",
playlistDownloadStarted: "Playlist download started",
collectionHasVideos: "This Bilibili collection has {count} videos.",
seriesHasVideos: "This Bilibili series has {count} videos.",
@@ -603,7 +653,8 @@ export const en = {
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
cloudflaredToken: "Tunnel Token (Optional)",
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
cloudflaredTokenHelper:
"Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
waitingForUrl: "Waiting for Quick Tunnel URL...",
running: "Running",
stopped: "Stopped",
@@ -611,8 +662,10 @@ export const en = {
accountTag: "Account Tag",
copied: "Copied!",
clickToCopy: "Click to copy",
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
quickTunnelWarning:
"Quick Tunnel URLs change every time the tunnel restarts.",
managedInDashboard:
"Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
// Database Export/Import
exportImportDatabase: "Export/Import Database",
@@ -643,5 +696,4 @@ export const en = {
noBackupAvailable: "No backup available",
failedToDownloadVideo: "Failed to download video. Please try again.",
failedToDownload: "Failed to download. Please try again.",
};

View File

@@ -56,11 +56,18 @@ export const es = {
websiteName: "Nombre del sitio web",
websiteNameHelper: "{current}/{max} caracteres (Predeterminado: {default})",
infiniteScroll: "Desplazamiento infinito",
infiniteScrollDisabled: "Desactivado cuando el desplazamiento infinito está habilitado",
infiniteScrollDisabled:
"Desactivado cuando el desplazamiento infinito está habilitado",
maxVideoColumns: "Columnas de video máximas (Página de inicio)",
videoColumns: "Columnas de video (Página de inicio)",
columnsCount: "{count} Columnas",
enableLogin: "Habilitar Protección de Inicio de Sesión",
allowPasswordLogin: "Permitir Inicio de Sesión con Contraseña",
allowPasswordLoginHelper:
"Cuando está deshabilitado, el inicio de sesión con contraseña no está disponible. Debe tener al menos una clave de acceso para deshabilitar el inicio de sesión con contraseña.",
allowResetPassword: "Permitir Restablecer Contraseña",
allowResetPasswordHelper:
"Cuando está deshabilitado, el botón de restablecer contraseña no se mostrará en la página de inicio de sesión y la API de restablecer contraseña será bloqueada.",
password: "Contraseña",
enterPassword: "Introducir contraseña",
togglePasswordVisibility: "Alternar visibilidad de contraseña",
@@ -137,10 +144,7 @@ export const es = {
itemsPerPageHelper:
"Número de videos para mostrar por página (Predeterminado: 12)",
showYoutubeSearch: "Mostrar resultados de búsqueda de YouTube",
visitorMode: "Modo Visitante (Solo lectura)",
visitorModeReadOnly: "Modo visitante: Solo lectura",
visitorModeDescription: "Modo de solo lectura. Los videos ocultos no serán visibles para los visitantes.",
visitorModePasswordPrompt: "Por favor, introduzca la contraseña del sitio web para cambiar la configuración del modo visitante.",
cleanupTempFilesSuccess:
"Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
@@ -170,11 +174,13 @@ export const es = {
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "URL Público",
publicUrlHelper: "Dominio público para acceder a archivos (ej. https://your-cloudflare-tunnel-domain.com). Si se establece, se usará en lugar de la URL de la API para acceder a archivos.",
publicUrlHelper:
"Dominio público para acceder a archivos (ej. https://your-cloudflare-tunnel-domain.com). Si se establece, se usará en lugar de la URL de la API para acceder a archivos.",
uploadPath: "Ruta de carga",
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
scanPaths: "Rutas de escaneo",
scanPathsHelper: "Una ruta por línea. Se escanearán videos de estas rutas. Si está vacío, se usará la ruta de carga. Ejemplo:\n/a/Peliculas\n/b/Documentales",
scanPathsHelper:
"Una ruta por línea. Se escanearán videos de estas rutas. Si está vacío, se usará la ruta de carga. Ejemplo:\n/a/Peliculas\n/b/Documentales",
cloudDriveNote:
"Después de habilitar esta función, los videos recién descargados se subirán automáticamente al almacenamiento en la nube y se eliminarán los archivos locales. Los videos se reproducirán desde el almacenamiento en la nube a través de un proxy.",
cloudScanAdded: "Añadido desde la nube",
@@ -182,26 +188,33 @@ export const es = {
testConnection: "Probar Conexión",
sync: "Sincronizar",
syncToCloud: "Sincronización bidireccional",
syncWarning: "Esta operación subirá videos locales a la nube y buscará nuevos archivos en el almacenamiento en la nube. Los archivos locales se eliminarán después de la carga.",
syncWarning:
"Esta operación subirá videos locales a la nube y buscará nuevos archivos en el almacenamiento en la nube. Los archivos locales se eliminarán después de la carga.",
syncing: "Sincronizando...",
syncCompleted: "Sincronización Completada",
syncFailed: "Sincronización Fallida",
syncReport: "Total: {total} | Cargados: {uploaded} | Fallidos: {failed}",
syncErrors: "Errores:",
fillApiUrlToken: "Por favor complete primero la URL de la API y el Token",
connectionTestSuccess: "¡Prueba de conexión exitosa! La configuración es válida.",
connectionFailedStatus: "Conexión fallida: El servidor devolvió el estado {status}",
connectionFailedUrl: "No se puede conectar al servidor. Por favor verifique la URL de la API.",
connectionTestSuccess:
"¡Prueba de conexión exitosa! La configuración es válida.",
connectionFailedStatus:
"Conexión fallida: El servidor devolvió el estado {status}",
connectionFailedUrl:
"No se puede conectar al servidor. Por favor verifique la URL de la API.",
authFailed: "Autentiación fallida. Por favor verifique su token.",
connectionTestFailed: "Prueba de conexión fallida: {error}",
syncFailedMessage: "Sincronización fallida. Por favor intente de nuevo.",
foundVideosToSync: "Se encontraron {count} videos con archivos locales para sincronizar",
foundVideosToSync:
"Se encontraron {count} videos con archivos locales para sincronizar",
uploadingVideo: "Subiendo: {title}",
clearThumbnailCache: "Borrar caché local de miniaturas",
clearing: "Borrando...",
clearThumbnailCacheSuccess: "Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
clearThumbnailCacheSuccess:
"Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
clearThumbnailCacheError: "Error al borrar el caché de miniaturas",
clearThumbnailCacheConfirmMessage: "Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
clearThumbnailCacheConfirmMessage:
"Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
manageContent: "Gestionar Contenido",
videos: "Videos",
@@ -300,7 +313,8 @@ export const es = {
openInExternalPlayer: "Abrir en reproductor externo",
playWith: "Reproducir con...",
deleteAllFilteredVideos: "Eliminar todos los videos filtrados",
confirmDeleteFilteredVideos: "¿Está seguro de que desea eliminar {count} videos filtrados por las etiquetas seleccionadas?",
confirmDeleteFilteredVideos:
"¿Está seguro de que desea eliminar {count} videos filtrados por las etiquetas seleccionadas?",
deleteFilteredVideosSuccess: "Se han eliminado {count} videos con éxito.",
deletingVideos: "Eliminando videos...",
signIn: "Iniciar Sesión",
@@ -320,10 +334,39 @@ export const es = {
resetPasswordConfirm: "Restablecer",
resetPasswordSuccess:
"La contraseña ha sido restablecida. Consulte los registros del backend para obtener la nueva contraseña.",
resetPasswordDisabledInfo:
"El restablecimiento de contraseña está deshabilitado. Para restablecer su contraseña, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nEsto generará una nueva contraseña aleatoria y habilitará el inicio de sesión con contraseña.",
resetPasswordScriptGuide:
"Para restablecer la contraseña manualmente, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nSi no se proporciona una contraseña, se generará una contraseña aleatoria de 8 caracteres.",
waitTimeMessage: "Por favor espere {time} antes de intentar nuevamente.",
tooManyAttempts: "Demasiados intentos fallidos.",
// Passkeys
createPasskey: "Crear clave de acceso",
creatingPasskey: "Creando...",
passkeyCreated: "Clave de acceso creada exitosamente",
passkeyCreationFailed:
"Error al crear la clave de acceso. Por favor, inténtelo de nuevo.",
removePasskeys: "Eliminar todas las claves de acceso",
removePasskeysTitle: "Eliminar todas las claves de acceso",
removePasskeysMessage:
"¿Está seguro de que desea eliminar todas las claves de acceso? Esta acción no se puede deshacer.",
passkeysRemoved: "Todas las claves de acceso han sido eliminadas",
passkeysRemoveFailed:
"Error al eliminar las claves de acceso. Por favor, inténtelo de nuevo.",
loginWithPasskey: "Iniciar sesión con clave de acceso",
authenticating: "Autenticando...",
passkeyLoginFailed:
"Error en la autenticación con clave de acceso. Por favor, inténtelo de nuevo.",
passkeyErrorPermissionDenied:
"La solicitud no está permitida por el agente de usuario o la plataforma en el contexto actual, posiblemente porque el usuario denegó el permiso.",
passkeyErrorAlreadyRegistered:
"El autenticador ya estaba registrado previamente.",
linkCopied: "Enlace copiado al portapapeles",
copyFailed: "Error al copiar enlace",
passkeyRequiresHttps:
"WebAuthn requiere HTTPS o localhost. Por favor, acceda a la aplicación a través de HTTPS o utilice localhost en lugar de una dirección IP.",
passkeyWebAuthnNotSupported:
"WebAuthn no es compatible con este navegador. Por favor, utilice un navegador moderno que sea compatible con WebAuthn.",
// Collection Page: "Cargando colección...", collectionNotFound: "Colección no encontrada",
noVideosInCollection: "No hay videos en esta colección.",
@@ -333,7 +376,8 @@ export const es = {
unknownAuthor: "Desconocido",
noVideosForAuthor: "No se encontraron videos para este autor.",
deleteAuthor: "Eliminar Autor",
deleteAuthorConfirmation: "¿Está seguro de que desea eliminar al autor {author}? Esto eliminará todos los videos asociados con este autor.",
deleteAuthorConfirmation:
"¿Está seguro de que desea eliminar al autor {author}? Esto eliminará todos los videos asociados con este autor.",
authorDeletedSuccessfully: "Autor eliminado con éxito",
failedToDeleteAuthor: "Error al eliminar autor",
deleteCollectionTitle: "Eliminar Colección",
@@ -358,6 +402,11 @@ export const es = {
unknownDate: "Fecha desconocida",
part: "Parte",
collection: "Colección",
justNow: "Ahora mismo",
hoursAgo: "Hace {hours} horas",
today: "Hoy",
thisWeek: "Esta semana",
weeksAgo: "Hace {weeks} semanas",
selectVideoFile: "Seleccionar Archivo de Video",
pleaseSelectVideo: "Por favor seleccione un archivo de video",
uploadFailed: "Carga fallida",
@@ -370,7 +419,8 @@ export const es = {
authorOrPlaylist: "Autor / Lista de reproducción",
playlistDetected: "Lista de reproducción detectada",
playlistHasVideos: "Esta lista de reproducción tiene {count} videos.",
downloadPlaylistAndCreateCollection: "¿Descargar videos de la lista de reproducción y crear una colección para ella?",
downloadPlaylistAndCreateCollection:
"¿Descargar videos de la lista de reproducción y crear una colección para ella?",
collectionHasVideos: "Esta colección de Bilibili tiene {count} videos.",
seriesHasVideos: "Esta serie de Bilibili tiene {count} videos.",
videoHasParts: "Este video de Bilibili tiene {count} partes.",
@@ -433,7 +483,8 @@ export const es = {
subscriptionAlreadyExists: "Ya estás suscrito a este autor.",
minutes: "minutos",
never: "Nunca",
downloadAllPreviousVideos: "Descargar todos los videos anteriores de este autor",
downloadAllPreviousVideos:
"Descargar todos los videos anteriores de este autor",
downloadAllPreviousWarning:
"Advertencia: Esto descargará todos los videos anteriores de este autor. Esto puede consumir un espacio de almacenamiento significativo y podría activar mecanismos de detección de bots que pueden resultar en prohibiciones temporales o permanentes de la plataforma. Úselo bajo su propio riesgo.",
continuousDownloadTasks: "Tareas de descarga continua",
@@ -443,14 +494,17 @@ export const es = {
taskStatusCancelled: "Cancelado",
downloaded: "Descargado",
cancelTask: "Cancelar tarea",
confirmCancelTask: "¿Estás seguro de que quieres cancelar la tarea de descarga para {author}?",
confirmCancelTask:
"¿Estás seguro de que quieres cancelar la tarea de descarga para {author}?",
taskCancelled: "Tarea cancelada exitosamente",
deleteTask: "Eliminar tarea",
confirmDeleteTask: "¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
confirmDeleteTask:
"¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
taskDeleted: "Tarea eliminada exitosamente",
clearFinishedTasks: "Borrar tareas finalizadas",
tasksCleared: "Tareas finalizadas borradas con éxito",
confirmClearFinishedTasks: "¿Está seguro de que desea borrar todas las tareas finalizadas (completadas, canceladas)? Esto las eliminará de la lista pero no borrará ningún archivo descargado.",
confirmClearFinishedTasks:
"¿Está seguro de que desea borrar todas las tareas finalizadas (completadas, canceladas)? Esto las eliminará de la lista pero no borrará ningún archivo descargado.",
clear: "Borrar",
// Instruction Page
instructionSection1Title: "1. Descarga y Gestión de Tareas",
@@ -581,7 +635,8 @@ export const es = {
cloudflaredTunnel: "Túnel Cloudflare",
enableCloudflaredTunnel: "Habilitar túnel Cloudflare",
cloudflaredToken: "Token del túnel (Opcional)",
cloudflaredTokenHelper: "Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
cloudflaredTokenHelper:
"Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
waitingForUrl: "Esperando URL de Quick Tunnel...",
running: "Ejecutando",
stopped: "Detenido",
@@ -589,8 +644,10 @@ export const es = {
accountTag: "Etiqueta de cuenta",
copied: "¡Copiado!",
clickToCopy: "Clic para copiar",
quickTunnelWarning: "Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
managedInDashboard: "El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
quickTunnelWarning:
"Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
managedInDashboard:
"El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
failedToDownloadVideo: "Error al descargar el video. Inténtalo de nuevo.",
failedToDownload: "Error al descargar. Inténtalo de nuevo.",
playlistDownloadStarted: "Descarga de lista de reproducción iniciada",
@@ -602,24 +659,44 @@ export const es = {
copyUrl: "Copiar URL",
new: "NUEVO",
// Task Hooks
taskHooks: 'Ganchos de Tarea',
taskHooksDescription: 'Ejecute comandos de shell personalizados en puntos específicos del ciclo de vida de la tarea. Variables de entorno disponibles: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Advertencia: Los comandos se ejecutan con los permisos del servidor. Úselo con precaución.',
hookTaskBeforeStart: 'Antes del Inicio de la Tarea',
hookTaskBeforeStartHelper: 'Se ejecuta antes de que comience la descarga.',
hookTaskSuccess: 'Tarea Exitosa',
hookTaskSuccessHelper: 'Se ejecuta después de una descarga exitosa, antes de la carga/eliminación en la nube (espera finalización).',
hookTaskFail: 'Tarea Fallida',
hookTaskFailHelper: 'Se ejecuta cuando falla una tarea.',
hookTaskCancel: 'Tarea Cancelada',
hookTaskCancelHelper: 'Se ejecuta cuando una tarea se cancela manualmente.',
found: 'Encontrado',
notFound: 'No Establecido',
deleteHook: 'Eliminar Script de Gancho',
confirmDeleteHook: '¿Está seguro de que desea eliminar este script de gancho?',
uploadHook: 'Subir .sh',
taskHooks: "Ganchos de Tarea",
taskHooksDescription:
"Ejecute comandos de shell personalizados en puntos específicos del ciclo de vida de la tarea. Variables de entorno disponibles: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Advertencia: Los comandos se ejecutan con los permisos del servidor. Úselo con precaución.",
hookTaskBeforeStart: "Antes del Inicio de la Tarea",
hookTaskBeforeStartHelper: "Se ejecuta antes de que comience la descarga.",
hookTaskSuccess: "Tarea Exitosa",
hookTaskSuccessHelper:
"Se ejecuta después de una descarga exitosa, antes de la carga/eliminación en la nube (espera finalización).",
hookTaskFail: "Tarea Fallida",
hookTaskFailHelper: "Se ejecuta cuando falla una tarea.",
hookTaskCancel: "Tarea Cancelada",
hookTaskCancelHelper: "Se ejecuta cuando una tarea se cancela manualmente.",
found: "Encontrado",
notFound: "No Establecido",
deleteHook: "Eliminar Script de Gancho",
confirmDeleteHook:
"¿Está seguro de que desea eliminar este script de gancho?",
uploadHook: "Subir .sh",
disclaimerTitle: "Descargo de responsabilidad",
disclaimerText: "1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
disclaimerText:
"1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
enterPasswordToUploadHook:
"Por favor ingrese su contraseña para subir este script de gancho.",
riskCommandDetected:
"Comando de riesgo detectado: {command}. Carga rechazada.",
// Visitor Mode
admin: "Administrador",
visitorSignIn: "Inicio de Sesión de Visitante",
visitorUser: "Usuario Visitante",
enableVisitorUser: "Habilitar Usuario Visitante",
visitorUserHelper:
"Habilite una cuenta de visitante separada con acceso de solo lectura. Los visitantes pueden ver el contenido pero no pueden realizar cambios.",
visitorPassword: "Contraseña de Visitante",
visitorPasswordHelper: "Establezca la contraseña para la cuenta de visitante.",
visitorPasswordSetHelper:
"La contraseña está establecida. Déjelo en blanco para mantenerla.",
};

View File

@@ -55,6 +55,12 @@ export const fr = {
videoColumns: "Colonnes vidéo (Accueil)",
columnsCount: "{count} Colonnes",
enableLogin: "Activer la protection par connexion",
allowPasswordLogin: "Autoriser la connexion par mot de passe",
allowPasswordLoginHelper:
"Lorsqu'elle est désactivée, la connexion par mot de passe n'est pas disponible. Vous devez avoir au moins une clé d'accès pour désactiver la connexion par mot de passe.",
allowResetPassword: "Autoriser la réinitialisation du mot de passe",
allowResetPasswordHelper:
"Lorsqu'elle est désactivée, le bouton de réinitialisation du mot de passe ne sera pas affiché sur la page de connexion et l'API de réinitialisation du mot de passe sera bloquée.",
password: "Mot de passe",
enterPassword: "Entrez le mot de passe",
togglePasswordVisibility: "Afficher/Masquer le mot de passe",
@@ -136,12 +142,7 @@ export const fr = {
itemsPerPage: "Éléments par page",
itemsPerPageHelper: "Nombre de vidéos à afficher par page (Défaut : 12)",
showYoutubeSearch: "Afficher les résultats de recherche YouTube",
visitorMode: "Mode Visiteur (Lecture seule)",
visitorModeReadOnly: "Mode visiteur : Lecture seule",
visitorModeDescription:
"Mode lecture seule. Les vidéos masquées ne seront pas visibles pour les visiteurs.",
visitorModePasswordPrompt:
"Veuillez entrer le mot de passe du site web pour modifier les paramètres du mode visiteur.",
cleanupTempFilesSuccess:
"{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
@@ -208,9 +209,11 @@ export const fr = {
uploadingVideo: "Téléversement : {title}",
clearThumbnailCache: "Vider le cache des miniatures locales",
clearing: "Nettoyage...",
clearThumbnailCacheSuccess: "Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
clearThumbnailCacheSuccess:
"Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
clearThumbnailCacheError: "Échec du vidage du cache des miniatures",
clearThumbnailCacheConfirmMessage: "Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
clearThumbnailCacheConfirmMessage:
"Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
// Manage
manageContent: "Gérer le contenu",
@@ -331,10 +334,39 @@ export const fr = {
resetPasswordConfirm: "Réinitialiser",
resetPasswordSuccess:
"Le mot de passe a été réinitialisé. Consultez les journaux du backend pour le nouveau mot de passe.",
resetPasswordDisabledInfo:
"La réinitialisation du mot de passe est désactivée. Pour réinitialiser votre mot de passe, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nCela générera un nouveau mot de passe aléatoire et activera la connexion par mot de passe.",
resetPasswordScriptGuide:
"Pour réinitialiser le mot de passe manuellement, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nSi aucun mot de passe n'est fourni, un mot de passe aléatoire de 8 caractères sera généré.",
waitTimeMessage: "Veuillez attendre {time} avant de réessayer.",
tooManyAttempts: "Trop de tentatives échouées.",
// Passkeys
createPasskey: "Créer une clé d'accès",
creatingPasskey: "Création en cours...",
passkeyCreated: "Clé d'accès créée avec succès",
passkeyCreationFailed:
"Échec de la création de la clé d'accès. Veuillez réessayer.",
removePasskeys: "Supprimer toutes les clés d'accès",
removePasskeysTitle: "Supprimer toutes les clés d'accès",
removePasskeysMessage:
"Êtes-vous sûr de vouloir supprimer toutes les clés d'accès ? Cette action ne peut pas être annulée.",
passkeysRemoved: "Toutes les clés d'accès ont été supprimées",
passkeysRemoveFailed:
"Échec de la suppression des clés d'accès. Veuillez réessayer.",
loginWithPasskey: "Se connecter avec une clé d'accès",
authenticating: "Authentification en cours...",
passkeyLoginFailed:
"Échec de l'authentification par clé d'accès. Veuillez réessayer.",
passkeyErrorPermissionDenied:
"La demande n'est pas autorisée par l'agent utilisateur ou la plateforme dans le contexte actuel, peut-être parce que l'utilisateur a refusé l'autorisation.",
passkeyErrorAlreadyRegistered:
"L'authentificateur a déjà été enregistré précédemment.",
linkCopied: "Lien copié dans le presse-papiers",
copyFailed: "Échec de la copie du lien",
passkeyRequiresHttps:
"WebAuthn nécessite HTTPS ou localhost. Veuillez accéder à l'application via HTTPS ou utiliser localhost au lieu d'une adresse IP.",
passkeyWebAuthnNotSupported:
"WebAuthn n'est pas supporté par ce navigateur. Veuillez utiliser un navigateur moderne qui supporte WebAuthn.",
// Collection Page
loadingCollection: "Chargement de la collection...",
@@ -381,6 +413,11 @@ export const fr = {
unknownDate: "Date inconnue",
part: "Partie",
collection: "Collection",
justNow: "À l'instant",
hoursAgo: "Il y a {hours} heures",
today: "Aujourd'hui",
thisWeek: "Cette semaine",
weeksAgo: "Il y a {weeks} semaines",
// Upload Modal
selectVideoFile: "Sélectionner un fichier vidéo",
@@ -397,7 +434,8 @@ export const fr = {
authorOrPlaylist: "Auteur / Playlist",
playlistDetected: "Playlist détectée",
playlistHasVideos: "Cette playlist contient {count} vidéos.",
downloadPlaylistAndCreateCollection: "Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
downloadPlaylistAndCreateCollection:
"Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
collectionHasVideos: "Cette collection Bilibili contient {count} vidéos.",
seriesHasVideos: "Cette série Bilibili contient {count} vidéos.",
videoHasParts: "Cette vidéo Bilibili contient {count} parties.",
@@ -484,7 +522,8 @@ export const fr = {
taskDeleted: "Tâche supprimée avec succès",
clearFinishedTasks: "Effacer les tâches terminées",
tasksCleared: "Tâches terminées effacées avec succès",
confirmClearFinishedTasks: "Êtes-vous sûr de vouloir effacer toutes les tâches terminées (complétées, annulées) ? Cela les supprimera de la liste mais ne supprimera aucun fichier téléchargé.",
confirmClearFinishedTasks:
"Êtes-vous sûr de vouloir effacer toutes les tâches terminées (complétées, annulées) ? Cela les supprimera de la liste mais ne supprimera aucun fichier téléchargé.",
clear: "Effacer",
// Instruction Page
instructionSection1Title: "1. Téléchargement et Gestion des Tâches",
@@ -628,7 +667,8 @@ export const fr = {
cloudflaredTunnel: "Tunnel Cloudflare",
enableCloudflaredTunnel: "Activer le tunnel Cloudflare",
cloudflaredToken: "Jeton de tunnel (Optionnel)",
cloudflaredTokenHelper: "Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
cloudflaredTokenHelper:
"Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
waitingForUrl: "En attente de l'URL Quick Tunnel...",
running: "En cours",
stopped: "Arrêté",
@@ -636,32 +676,54 @@ export const fr = {
accountTag: "Tag de compte",
copied: "Copié !",
clickToCopy: "Cliquer pour copier",
quickTunnelWarning: "Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
managedInDashboard: "Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
failedToDownloadVideo: "Échec du téléchargement de la vidéo. Veuillez réessayer.",
quickTunnelWarning:
"Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
managedInDashboard:
"Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
failedToDownloadVideo:
"Échec du téléchargement de la vidéo. Veuillez réessayer.",
failedToDownload: "Échec du téléchargement. Veuillez réessayer.",
playlistDownloadStarted: "Téléchargement de la playlist commencé",
copyUrl: "Copier l'URL",
new: "NOUVEAU",
// Task Hooks
taskHooks: 'Crochets de Tâche',
taskHooksDescription: 'Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d\'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Avertissement : Les commandes s\'exécutent avec les permissions du serveur. À utiliser avec prudence.',
hookTaskBeforeStart: 'Avant le Début de la Tâche',
hookTaskBeforeStartHelper: 'S\'exécute avant le début du téléchargement.',
hookTaskSuccess: 'Tâche Réussie',
hookTaskSuccessHelper: 'S\'exécute après un téléchargement réussi, avant le téléchargement/suppression cloud (attend la fin).',
hookTaskFail: 'Tâche Échouée',
hookTaskFailHelper: 'S\'exécute lorsqu\'une tâche échoue.',
hookTaskCancel: 'Tâche Annulée',
hookTaskCancelHelper: 'S\'exécute lorsqu\'une tâche est annulée manuellement.',
found: 'Trouvé',
notFound: 'Non Défini',
deleteHook: 'Supprimer le Script de Crochet',
confirmDeleteHook: 'Êtes-vous sûr de vouloir supprimer ce script de crochet ?',
uploadHook: 'Téléverser .sh',
taskHooks: "Crochets de Tâche",
taskHooksDescription:
"Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Avertissement : Les commandes s'exécutent avec les permissions du serveur. À utiliser avec prudence.",
enterPasswordToUploadHook:
"Veuillez entrer votre mot de passe pour télécharger ce script Hook.",
riskCommandDetected:
"Commande à risque détectée : {command}. Téléchargement rejeté.",
hookTaskBeforeStart: "Avant le Début de la Tâche",
hookTaskBeforeStartHelper: "S'exécute avant le début du téléchargement.",
hookTaskSuccess: "Tâche Réussie",
hookTaskSuccessHelper:
"S'exécute après un téléchargement réussi, avant le téléchargement/suppression cloud (attend la fin).",
hookTaskFail: "Tâche Échouée",
hookTaskFailHelper: "S'exécute lorsqu'une tâche échoue.",
hookTaskCancel: "Tâche Annulée",
hookTaskCancelHelper: "S'exécute lorsqu'une tâche est annulée manuellement.",
found: "Trouvé",
notFound: "Non Défini",
deleteHook: "Supprimer le Script de Crochet",
confirmDeleteHook:
"Êtes-vous sûr de vouloir supprimer ce script de crochet ?",
uploadHook: "Téléverser .sh",
disclaimerTitle: "Avis de non-responsabilité",
disclaimerText: "1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
disclaimerText:
"1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
// Visitor Mode
admin: "Administrateur",
visitorSignIn: "Connexion Visiteur",
visitorUser: "Utilisateur Visiteur",
enableVisitorUser: "Activer l'Utilisateur Visiteur",
visitorUserHelper:
"Activez un compte visiteur séparé avec un accès en lecture seule. Les visiteurs peuvent voir le contenu mais ne peuvent pas effectuer de modifications.",
visitorPassword: "Mot de passe Visiteur",
visitorPasswordHelper: "Définissez le mot de passe pour le compte visiteur.",
visitorPasswordSetHelper:
"Le mot de passe est défini. Laisser vide pour le conserver.",
};

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