Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79530dbca2 | ||
|
|
f48066c045 | ||
|
|
46c8d7730f | ||
|
|
fbd55b0037 | ||
|
|
6490e1f912 | ||
|
|
16ba5ac1d4 | ||
|
|
f76acfdcf1 | ||
|
|
98ec0b342f | ||
|
|
c995eb3637 | ||
|
|
8e533e3615 | ||
|
|
7dbf5c895d | ||
|
|
eeac567523 | ||
|
|
10c857865c | ||
|
|
e7bdf182c5 | ||
|
|
a5e82b9e81 | ||
|
|
d99a210174 | ||
|
|
50cc94a44e | ||
|
|
ccd2729f71 | ||
|
|
a9f78647e4 | ||
|
|
e18f49d321 | ||
|
|
13de853a54 | ||
|
|
76d4269164 | ||
|
|
44b24543d0 | ||
|
|
b6fbf015a3 | ||
|
|
9c0afb0693 | ||
|
|
3717296bf2 | ||
|
|
fe8dd04f08 | ||
|
|
e0819ca42c | ||
|
|
092a79f635 | ||
|
|
9296390b82 | ||
|
|
35aa348824 | ||
|
|
1b9451bffa | ||
|
|
9968268975 | ||
|
|
ce544ff9c2 | ||
|
|
b6e3072350 | ||
|
|
85424624ca | ||
|
|
6fdfa90d01 | ||
|
|
c9657bad51 | ||
|
|
2d9d7b37a6 | ||
|
|
b8fcb05d51 | ||
|
|
90a24454f6 | ||
|
|
a56de30dd1 | ||
|
|
b8cc540f9d | ||
|
|
b546a4520e | ||
|
|
6bbb40eb11 | ||
|
|
c00b552ba9 | ||
|
|
845e1847f7 | ||
|
|
71d59a9e26 | ||
|
|
4e8d7553ea | ||
|
|
e1fb345094 | ||
|
|
351f1876d7 | ||
|
|
c32fa3e7ca | ||
|
|
b0428b9813 |
114
CHANGELOG.md
114
CHANGELOG.md
@@ -1,6 +1,120 @@
|
||||
# Change Log
|
||||
|
||||
|
||||
## v1.7.32 (2026-01-04)
|
||||
|
||||
### Style
|
||||
|
||||
- style: Update cookie security settings for better usability (46c8d77)
|
||||
|
||||
## v1.7.31 (2026-01-04)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Implement helper for selecting best m3u8 URL (f76acfd)
|
||||
- feat: Add executeYtDlpJson function (98ec0b3)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Update axios configuration for downloading subtitles (c995eb3)
|
||||
|
||||
## v1.7.30 (2026-01-04)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Update mock SettingsPage test to include refetch (eeac567)
|
||||
|
||||
### Style
|
||||
|
||||
- style: Improve comments for YtDlpSettings file (e7bdf18)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Add file_location test and mock settings in ytdlpVideo (a5e82b9)
|
||||
|
||||
## v1.7.29 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Enable visitor user with password option (ccd2729)
|
||||
- feat: enhance visitor mode (e18f49d)
|
||||
- feat: enhance visitor mode (13de853)
|
||||
- feat: enhance visitor mode (76d4269)
|
||||
- feat: Add visitor mode in LoginPage component (44b2454)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Add role to response in passwordController tests (a9f7864)
|
||||
|
||||
## v1.7.28 (2026-01-03)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve m3u8 URL selection strategy (3717296)
|
||||
|
||||
## v1.7.27 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add endpoint for retrieving reset password cooldown (092a79f)
|
||||
- feat: Add WebAuthn error translations (9296390)
|
||||
|
||||
## v1.7.26 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add script to reset password securely (1b9451b)
|
||||
- feat: Add allowResetPassword setting and UI components (9968268)
|
||||
- feat: Add password login permission handling (ce544ff)
|
||||
|
||||
## v1.7.25 (2026-01-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: add passkey feature (6fdfa90)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Update formatUtils to use formatRelativeDownloadTime function (c9657ba)
|
||||
|
||||
## v1.7.24 (2026-01-01)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Explicitly preserve network-related options (90a2445)
|
||||
|
||||
## v1.7.23 (2026-01-01)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add logic to refresh thumbnail with random timestamp (6bbb40e)
|
||||
- feat: Add reset password route and update dependencies (c00b552)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add reset password route (845e184)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities (b0428b9)
|
||||
|
||||
## v1.7.22 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add risk command scanning for hook uploads (c32fa3e)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve handling of absolute paths in security functions (351f187)
|
||||
|
||||
## v1.7.21 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add hook functionality for task lifecycle (6f1a1cd)
|
||||
- feat: add task hooks (8ac9e99)
|
||||
|
||||
## v1.7.20 (2025-12-30)
|
||||
|
||||
### Chore
|
||||
|
||||
11
backend/drizzle/0009_brief_stingray.sql
Normal file
11
backend/drizzle/0009_brief_stingray.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `passkeys` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`credential_id` text NOT NULL,
|
||||
`credential_public_key` text NOT NULL,
|
||||
`counter` integer DEFAULT 0 NOT NULL,
|
||||
`transports` text,
|
||||
`name` text,
|
||||
`created_at` text NOT NULL,
|
||||
`rp_id` text,
|
||||
`origin` text
|
||||
);
|
||||
907
backend/drizzle/meta/0009_snapshot.json
Normal file
907
backend/drizzle/meta/0009_snapshot.json
Normal file
@@ -0,0 +1,907 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "5627912c-5cc6-4da0-8d67-e5f73a7b4736",
|
||||
"prevId": "e727cb82-6923-4f2f-a2dd-459a8a052879",
|
||||
"tables": {
|
||||
"collection_videos": {
|
||||
"name": "collection_videos",
|
||||
"columns": {
|
||||
"collection_id": {
|
||||
"name": "collection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_id": {
|
||||
"name": "video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"order": {
|
||||
"name": "order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"collection_videos_collection_id_collections_id_fk": {
|
||||
"name": "collection_videos_collection_id_collections_id_fk",
|
||||
"tableFrom": "collection_videos",
|
||||
"tableTo": "collections",
|
||||
"columnsFrom": [
|
||||
"collection_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"collection_videos_video_id_videos_id_fk": {
|
||||
"name": "collection_videos_video_id_videos_id_fk",
|
||||
"tableFrom": "collection_videos",
|
||||
"tableTo": "videos",
|
||||
"columnsFrom": [
|
||||
"video_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"collection_videos_collection_id_video_id_pk": {
|
||||
"columns": [
|
||||
"collection_id",
|
||||
"video_id"
|
||||
],
|
||||
"name": "collection_videos_collection_id_video_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"collections": {
|
||||
"name": "collections",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"continuous_download_tasks": {
|
||||
"name": "continuous_download_tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subscription_id": {
|
||||
"name": "subscription_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"collection_id": {
|
||||
"name": "collection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_url": {
|
||||
"name": "author_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"total_videos": {
|
||||
"name": "total_videos",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"downloaded_count": {
|
||||
"name": "downloaded_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"skipped_count": {
|
||||
"name": "skipped_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"failed_count": {
|
||||
"name": "failed_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"current_video_index": {
|
||||
"name": "current_video_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"download_history": {
|
||||
"name": "download_history",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"finished_at": {
|
||||
"name": "finished_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_path": {
|
||||
"name": "video_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_size": {
|
||||
"name": "total_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_id": {
|
||||
"name": "video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"downloads": {
|
||||
"name": "downloads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_size": {
|
||||
"name": "total_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"downloaded_size": {
|
||||
"name": "downloaded_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"speed": {
|
||||
"name": "speed",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"passkeys": {
|
||||
"name": "passkeys",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credential_id": {
|
||||
"name": "credential_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credential_public_key": {
|
||||
"name": "credential_public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"counter": {
|
||||
"name": "counter",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"transports": {
|
||||
"name": "transports",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rp_id": {
|
||||
"name": "rp_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"subscriptions": {
|
||||
"name": "subscriptions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author_url": {
|
||||
"name": "author_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"interval": {
|
||||
"name": "interval",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_video_link": {
|
||||
"name": "last_video_link",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_check": {
|
||||
"name": "last_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"download_count": {
|
||||
"name": "download_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'YouTube'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"video_downloads": {
|
||||
"name": "video_downloads",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_video_id": {
|
||||
"name": "source_video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"platform": {
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_id": {
|
||||
"name": "video_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'exists'"
|
||||
},
|
||||
"downloaded_at": {
|
||||
"name": "downloaded_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"videos": {
|
||||
"name": "videos",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_url": {
|
||||
"name": "source_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_filename": {
|
||||
"name": "video_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_filename": {
|
||||
"name": "thumbnail_filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"video_path": {
|
||||
"name": "video_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_path": {
|
||||
"name": "thumbnail_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"thumbnail_url": {
|
||||
"name": "thumbnail_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"added_at": {
|
||||
"name": "added_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"part_number": {
|
||||
"name": "part_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"total_parts": {
|
||||
"name": "total_parts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"series_title": {
|
||||
"name": "series_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"view_count": {
|
||||
"name": "view_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"duration": {
|
||||
"name": "duration",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"progress": {
|
||||
"name": "progress",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"file_size": {
|
||||
"name": "file_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_played_at": {
|
||||
"name": "last_played_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"subtitles": {
|
||||
"name": "subtitles",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"channel_url": {
|
||||
"name": "channel_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1766776202201,
|
||||
"tag": "0008_useful_sharon_carter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1767494996743,
|
||||
"tag": "0009_brief_stingray",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
468
backend/package-lock.json
generated
468
backend/package-lock.json
generated
@@ -1,24 +1,28 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.32",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.32",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -30,6 +34,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
@@ -628,6 +633,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -791,6 +802,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -814,6 +831,165 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
|
||||
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz",
|
||||
"integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz",
|
||||
"integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
|
||||
"integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz",
|
||||
"integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz",
|
||||
"integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz",
|
||||
"integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pfx": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
|
||||
"integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
|
||||
"integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz",
|
||||
"integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz",
|
||||
"integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -1177,6 +1353,25 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz",
|
||||
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@peculiar/x509": "^1.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||
@@ -1233,7 +1428,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -1255,12 +1449,20 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -1296,8 +1498,8 @@
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -1308,7 +1510,6 @@
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -1332,7 +1533,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonfile": {
|
||||
@@ -1345,6 +1545,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -1356,6 +1567,12 @@
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1373,7 +1590,6 @@
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -1391,21 +1607,18 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -1415,7 +1628,6 @@
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -1427,7 +1639,6 @@
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -1808,6 +2019,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2162,6 +2387,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -2464,14 +2695,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
@@ -2934,6 +3178,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -3267,39 +3520,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz",
|
||||
"integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -3312,6 +3565,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -4235,12 +4503,103 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -5109,6 +5468,24 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -5190,6 +5567,12 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -6101,6 +6484,24 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@@ -6173,7 +6574,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.32",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
@@ -9,6 +9,7 @@
|
||||
"generate": "drizzle-kit generate",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"reset-password": "ts-node scripts/reset-password.ts",
|
||||
"postinstall": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -16,15 +17,19 @@
|
||||
"license": "ISC",
|
||||
"description": "Backend for MyTube video streaming website",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -36,6 +41,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
|
||||
155
backend/scripts/reset-password.ts
Normal file
155
backend/scripts/reset-password.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Script to directly reset password and enable password login in the database
|
||||
*
|
||||
* Usage:
|
||||
* npm run reset-password [new-password]
|
||||
* or
|
||||
* ts-node scripts/reset-password.ts [new-password]
|
||||
*
|
||||
* If no password is provided, a random 8-character password will be generated.
|
||||
* The script will:
|
||||
* 1. Hash the password using bcrypt
|
||||
* 2. Update the password in the settings table
|
||||
* 3. Set passwordLoginAllowed to true
|
||||
* 4. Set loginEnabled to true
|
||||
* 5. Display the new password (if generated)
|
||||
*
|
||||
* Examples:
|
||||
* npm run reset-password # Generate random password
|
||||
* npm run reset-password mynewpassword123 # Set specific password
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Determine database path
|
||||
const ROOT_DIR = process.cwd();
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT_DIR, "data");
|
||||
// Normalize and resolve paths to prevent path traversal
|
||||
const normalizedDataDir = path.normalize(path.resolve(DATA_DIR));
|
||||
const dbPath = path.normalize(path.resolve(normalizedDataDir, "mytube.db"));
|
||||
|
||||
// Validate that the database path is within the expected directory
|
||||
// This prevents path traversal attacks via environment variables
|
||||
const resolvedDataDir = path.resolve(normalizedDataDir);
|
||||
const resolvedDbPath = path.resolve(dbPath);
|
||||
if (!resolvedDbPath.startsWith(resolvedDataDir + path.sep) && resolvedDbPath !== resolvedDataDir) {
|
||||
console.error("Error: Invalid database path detected (path traversal attempt)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure SQLite database for compatibility
|
||||
*/
|
||||
function configureDatabase(db: Database.Database): void {
|
||||
db.pragma("journal_mode = DELETE");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("busy_timeout = 5000");
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random password
|
||||
*/
|
||||
function generateRandomPassword(length: number = 8): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const randomBytes = crypto.randomBytes(length);
|
||||
return Array.from(randomBytes, (byte) => chars.charAt(byte % chars.length)).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt
|
||||
*/
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
return await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to reset password and enable password login
|
||||
*/
|
||||
async function resetPassword(newPassword?: string): Promise<void> {
|
||||
// Check if database exists
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`Error: Database not found at ${dbPath}`);
|
||||
console.error("Please ensure the MyTube backend has been started at least once.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate password if not provided
|
||||
const password = newPassword || generateRandomPassword(8);
|
||||
const isGenerated = !newPassword;
|
||||
|
||||
// Hash the password
|
||||
console.log("Hashing password...");
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Connect to database
|
||||
console.log(`Connecting to database at ${dbPath}...`);
|
||||
const db = new Database(dbPath);
|
||||
configureDatabase(db);
|
||||
|
||||
try {
|
||||
// Start transaction
|
||||
db.transaction(() => {
|
||||
// Update password
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('password', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(JSON.stringify(hashedPassword));
|
||||
|
||||
// Set passwordLoginAllowed to true
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('passwordLoginAllowed', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(JSON.stringify(true));
|
||||
|
||||
// Set loginEnabled to true
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value)
|
||||
VALUES ('loginEnabled', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
`).run(JSON.stringify(true));
|
||||
})();
|
||||
|
||||
console.log("✓ Password reset successfully");
|
||||
console.log("✓ Password login enabled");
|
||||
console.log("✓ Login enabled");
|
||||
|
||||
if (isGenerated) {
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("NEW PASSWORD (save this securely):");
|
||||
console.log(password);
|
||||
console.log("=".repeat(50));
|
||||
console.log("\n⚠️ This password will not be shown again!");
|
||||
} else {
|
||||
console.log("\n✓ Password has been set to the provided value");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating database:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const providedPassword = args[0];
|
||||
|
||||
// Run the script
|
||||
resetPassword(providedPassword).catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe('passwordController', () => {
|
||||
mockRes = {
|
||||
json: jsonMock,
|
||||
status: statusMock,
|
||||
cookie: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -39,12 +40,16 @@ describe('passwordController', () => {
|
||||
describe('verifyPassword', () => {
|
||||
it('should return success: true if verified', async () => {
|
||||
mockReq.body = { password: 'pass' };
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({ success: true });
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({
|
||||
success: true,
|
||||
token: 'mock-token',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
await passwordController.verifyPassword(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(passwordService.verifyPassword).toHaveBeenCalledWith('pass');
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ success: true });
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ success: true, role: 'admin' });
|
||||
});
|
||||
|
||||
it('should return 401 if incorrect', async () => {
|
||||
@@ -57,10 +62,8 @@ describe('passwordController', () => {
|
||||
|
||||
await passwordController.verifyPassword(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Incorrect'
|
||||
success: false
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -74,7 +77,6 @@ describe('passwordController', () => {
|
||||
|
||||
await passwordController.verifyPassword(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(429);
|
||||
expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: false,
|
||||
waitTime: 60
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('SettingsController', () => {
|
||||
res = {
|
||||
json,
|
||||
status,
|
||||
cookie: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -95,12 +96,16 @@ describe('SettingsController', () => {
|
||||
it('should verify correct password', async () => {
|
||||
req.body = { password: 'pass' };
|
||||
const passwordService = await import('../../services/passwordService');
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({ success: true });
|
||||
(passwordService.verifyPassword as any).mockResolvedValue({
|
||||
success: true,
|
||||
token: 'mock-token',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
|
||||
expect(passwordService.verifyPassword).toHaveBeenCalledWith('pass');
|
||||
expect(json).toHaveBeenCalledWith({ success: true });
|
||||
expect(json).toHaveBeenCalledWith({ success: true, role: 'admin' });
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
@@ -114,10 +119,8 @@ describe('SettingsController', () => {
|
||||
await verifyPassword(req as Request, res as Response);
|
||||
|
||||
expect(passwordService.verifyPassword).toHaveBeenCalledWith('wrong');
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Incorrect password'
|
||||
success: false
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as videoMetadataController from '../../controllers/videoMetadataController';
|
||||
import * as metadataService from '../../services/metadataService';
|
||||
import * as storageService from '../../services/storageService';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/storageService');
|
||||
vi.mock('../../services/metadataService', () => ({
|
||||
getVideoDuration: vi.fn()
|
||||
}));
|
||||
vi.mock('../../utils/security', () => ({
|
||||
validateVideoPath: vi.fn((path) => path),
|
||||
validateImagePath: vi.fn((path) => path),
|
||||
@@ -108,4 +112,37 @@ describe('videoMetadataController', () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshThumbnail', () => {
|
||||
it('should refresh thumbnail with random timestamp', async () => {
|
||||
mockReq.params = { id: '123' };
|
||||
const mockVideo = {
|
||||
id: '123',
|
||||
videoPath: '/videos/test.mp4',
|
||||
thumbnailPath: '/images/test.jpg',
|
||||
thumbnailFilename: 'test.jpg'
|
||||
};
|
||||
(storageService.getVideoById as any).mockReturnValue(mockVideo);
|
||||
(metadataService.getVideoDuration as any).mockResolvedValue(100); // 100 seconds duration
|
||||
|
||||
await videoMetadataController.refreshThumbnail(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(storageService.getVideoById).toHaveBeenCalledWith('123');
|
||||
expect(metadataService.getVideoDuration).toHaveBeenCalled();
|
||||
|
||||
// Verify execFileSafe was called with ffmpeg
|
||||
// The exact arguments depend on the random timestamp, but we can verify the structure
|
||||
const security = await import('../../utils/security');
|
||||
expect(security.execFileSafe).toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.arrayContaining([
|
||||
'-i', expect.stringContaining('test.mp4'),
|
||||
'-ss', expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/),
|
||||
'-vframes', '1',
|
||||
expect.stringContaining('test.jpg'),
|
||||
'-y'
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { visitorModeMiddleware } from '../../middleware/visitorModeMiddleware';
|
||||
import * as storageService from '../../services/storageService';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/storageService');
|
||||
|
||||
describe('visitorModeMiddleware', () => {
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let next: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReq = {
|
||||
method: 'GET',
|
||||
body: {},
|
||||
path: '/api/something',
|
||||
url: '/api/something'
|
||||
};
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn()
|
||||
};
|
||||
next = vi.fn();
|
||||
});
|
||||
|
||||
it('should call next if visitor mode disabled', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: false });
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow GET requests in visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.method = 'GET';
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block POST requests unless disabling visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.method = 'POST';
|
||||
mockReq.body = { someSetting: true };
|
||||
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('should allow disabling visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.method = 'POST';
|
||||
mockReq.body = { visitorMode: false };
|
||||
|
||||
visitorModeMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { visitorModeSettingsMiddleware } from '../../middleware/visitorModeSettingsMiddleware';
|
||||
import * as storageService from '../../services/storageService';
|
||||
|
||||
vi.mock('../../services/storageService');
|
||||
|
||||
describe('visitorModeSettingsMiddleware', () => {
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let next: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockReq = {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
path: '/api/settings',
|
||||
url: '/api/settings'
|
||||
};
|
||||
mockRes = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn()
|
||||
};
|
||||
next = vi.fn();
|
||||
});
|
||||
|
||||
it('should allow cloudflare updates in visitor mode', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.body = { cloudflaredTunnelEnabled: true };
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block other updates', () => {
|
||||
(storageService.getSettings as any).mockReturnValue({ visitorMode: true });
|
||||
mockReq.body = { websiteName: 'Hacked' };
|
||||
|
||||
visitorModeSettingsMiddleware(mockReq as Request, mockRes as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MissAVDownloader } from '../../../services/downloaders/MissAVDownloader';
|
||||
|
||||
describe('MissAVDownloader URL Selection', () => {
|
||||
describe('selectBestM3u8Url', () => {
|
||||
it('should prioritize surrit.com master playlist over other specific quality playlists', () => {
|
||||
const urls = [
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8',
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/480p/video.m3u8',
|
||||
'https://edge-hls.growcdnssedge.com/hls/121964773/master/121964773_240p.m3u8',
|
||||
'https://media-hls.growcdnssedge.com/b-hls-18/121964773/121964773_240p.m3u8'
|
||||
];
|
||||
|
||||
// Default behavior (no format sort)
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
expect(selected).toBe('https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8');
|
||||
});
|
||||
|
||||
it('should prioritize higher resolution when multiple surrit URLs exist', () => {
|
||||
const urls = [
|
||||
'https://surrit.com/uuid/playlist.m3u8', // Master
|
||||
'https://surrit.com/uuid/720p/video.m3u8',
|
||||
'https://surrit.com/uuid/480p/video.m3u8'
|
||||
];
|
||||
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
// If we have specific qualities, we usually prefer the highest specific one if no format sort is used,
|
||||
// OR we might prefer the master if we trust yt-dlp to pick best.
|
||||
// Based on typical behavior without format sort: existing logic preferred specific resolutions.
|
||||
// But for MissAV, playlist.m3u8 is usually more reliable/complete.
|
||||
// Let's assume we want to stick with Master if available for surrit.
|
||||
expect(selected).toContain('playlist.m3u8');
|
||||
// OR if we keep logic "prefer specific quality", then 720p.
|
||||
// The requirement is "Prioritize surrit.com URLs... prefer playlist.m3u8 (generic master) over specific resolution masters if the specific resolution is low/suspicious"
|
||||
// In this case 720p is good.
|
||||
// However, usually playlist.m3u8 contains all variants.
|
||||
});
|
||||
|
||||
it('should fallback to resolution comparison if no surrit URLs', () => {
|
||||
const urls = [
|
||||
'https://other.com/video_240p.m3u8',
|
||||
'https://other.com/video_720p.m3u8',
|
||||
'https://other.com/video_480p.m3u8'
|
||||
];
|
||||
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
expect(selected).toBe('https://other.com/video_720p.m3u8');
|
||||
});
|
||||
|
||||
it('should handle real world scenario from logs', () => {
|
||||
// From user log
|
||||
const urls = [
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8',
|
||||
'https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/480p/video.m3u8',
|
||||
'https://media-hls.growcdnssedge.com/b-hls-18/121964773/121964773_240p.m3u8',
|
||||
'https://edge-hls.growcdnssedge.com/hls/121964773/master/121964773_240p.m3u8'
|
||||
];
|
||||
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, false);
|
||||
// The bug was it picked the last one (edge-hls...240p.m3u8) or similar.
|
||||
// We want the surrit playlist.
|
||||
expect(selected).toBe('https://surrit.com/9183fb8b-9d17-43c7-b429-4c28b7813e2c/playlist.m3u8');
|
||||
});
|
||||
|
||||
it('should respect format sort when enabled', () => {
|
||||
const urls = [
|
||||
'https://surrit.com/uuid/playlist.m3u8',
|
||||
'https://surrit.com/uuid/480p/video.m3u8'
|
||||
];
|
||||
// With format sort, we DEFINITELY want the master playlist so yt-dlp can do the sorting
|
||||
const selected = MissAVDownloader.selectBestM3u8Url(urls, true);
|
||||
expect(selected).toBe('https://surrit.com/uuid/playlist.m3u8');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ vi.mock('../../../services/storageService', () => ({
|
||||
saveVideo: vi.fn(),
|
||||
getVideoBySourceUrl: vi.fn(),
|
||||
updateVideo: vi.fn(),
|
||||
getSettings: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Mock fs-extra - define mockWriter inside the factory
|
||||
|
||||
168
backend/src/__tests__/services/downloaders/file_location.test.ts
Normal file
168
backend/src/__tests__/services/downloaders/file_location.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Use vi.hoisted to ensure mocks are available for vi.mock factory
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
executeYtDlpSpawn: vi.fn(),
|
||||
executeYtDlpJson: vi.fn(),
|
||||
getUserYtDlpConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Setup default return values in the factory or beforeEach
|
||||
mocks.executeYtDlpJson.mockResolvedValue({
|
||||
title: 'Test Video',
|
||||
uploader: 'Test Author',
|
||||
upload_date: '20230101',
|
||||
thumbnail: 'http://example.com/thumb.jpg',
|
||||
extractor: 'youtube'
|
||||
});
|
||||
mocks.getUserYtDlpConfig.mockReturnValue({});
|
||||
mocks.getSettings.mockReturnValue({});
|
||||
mocks.readdirSync.mockReturnValue([]);
|
||||
mocks.readFileSync.mockReturnValue('WEBVTT');
|
||||
|
||||
vi.mock('../../../config/paths', () => ({
|
||||
VIDEOS_DIR: '/mock/videos',
|
||||
IMAGES_DIR: '/mock/images',
|
||||
SUBTITLES_DIR: '/mock/subtitles',
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/ytDlpUtils', () => ({
|
||||
executeYtDlpSpawn: (...args: any[]) => mocks.executeYtDlpSpawn(...args),
|
||||
executeYtDlpJson: (...args: any[]) => mocks.executeYtDlpJson(...args),
|
||||
getUserYtDlpConfig: (...args: any[]) => mocks.getUserYtDlpConfig(...args),
|
||||
getNetworkConfigFromUserConfig: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/storageService', () => ({
|
||||
updateActiveDownload: vi.fn(),
|
||||
saveVideo: vi.fn(),
|
||||
getVideoBySourceUrl: vi.fn(),
|
||||
updateVideo: vi.fn(),
|
||||
getSettings: () => mocks.getSettings(),
|
||||
}));
|
||||
|
||||
// Mock processSubtitles to verify it receives correct arguments
|
||||
// We need to access the actual implementation in logic but for this test checking arguments might be enough
|
||||
// However, the real test is seeing if paths are correct in downloadVideo
|
||||
// And we want to test processSubtitles logic too.
|
||||
|
||||
// Let's mock fs-extra completely
|
||||
vi.mock('fs-extra', () => {
|
||||
return {
|
||||
default: {
|
||||
pathExists: vi.fn().mockResolvedValue(false),
|
||||
ensureDirSync: vi.fn(),
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
createWriteStream: vi.fn().mockReturnValue({
|
||||
on: (event: string, cb: any) => {
|
||||
if (event === 'finish') cb();
|
||||
return { on: vi.fn() };
|
||||
}
|
||||
}),
|
||||
readdirSync: (...args: any[]) => mocks.readdirSync(...args),
|
||||
readFileSync: (...args: any[]) => mocks.readFileSync(...args),
|
||||
writeFileSync: (...args: any[]) => mocks.writeFileSync(...args),
|
||||
copyFileSync: vi.fn(),
|
||||
unlinkSync: (...args: any[]) => mocks.unlinkSync(...args),
|
||||
remove: (...args: any[]) => mocks.remove(...args),
|
||||
statSync: vi.fn().mockReturnValue({ size: 1000 }),
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
pipe: (writer: any) => {
|
||||
// Simulate write finish if writer has on method
|
||||
if (writer.on) {
|
||||
// Find and call finish handler manually if needed
|
||||
// But strictly relying on the createWriteStream mock above handling it
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/metadataService', () => ({
|
||||
getVideoDuration: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/downloadUtils', () => ({
|
||||
isDownloadActive: vi.fn().mockReturnValue(true), // Always active
|
||||
isCancellationError: vi.fn().mockReturnValue(false),
|
||||
cleanupSubtitleFiles: vi.fn(),
|
||||
cleanupVideoArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the modules under test
|
||||
import { processSubtitles } from '../../../services/downloaders/ytdlp/ytdlpSubtitle';
|
||||
|
||||
describe('File Location Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.executeYtDlpSpawn.mockReturnValue({
|
||||
stdout: { on: vi.fn() },
|
||||
kill: vi.fn(),
|
||||
then: (resolve: any) => resolve()
|
||||
});
|
||||
mocks.readdirSync.mockReturnValue([]);
|
||||
// Reset default mock implementations if needed, but they are set on the object so clearer to set logic in test
|
||||
});
|
||||
|
||||
// describe('downloadVideo', () => {});
|
||||
|
||||
describe('processSubtitles', () => {
|
||||
it('should move subtitles to SUBTITLES_DIR by default', async () => {
|
||||
const baseFilename = 'video_123';
|
||||
mocks.readdirSync.mockReturnValue(['video_123.en.vtt']);
|
||||
mocks.readFileSync.mockReturnValue('WEBVTT');
|
||||
|
||||
await processSubtitles(baseFilename, 'download_id', false);
|
||||
|
||||
expect(mocks.writeFileSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/subtitles', 'video_123.en.vtt'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
expect(mocks.unlinkSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/videos', 'video_123.en.vtt')
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep subtitles in VIDEOS_DIR if moveSubtitlesToVideoFolder is true', async () => {
|
||||
const baseFilename = 'video_123';
|
||||
mocks.readdirSync.mockReturnValue(['video_123.en.vtt']);
|
||||
mocks.readFileSync.mockReturnValue('WEBVTT');
|
||||
|
||||
await processSubtitles(baseFilename, 'download_id', true);
|
||||
|
||||
// Expect destination to be VIDEOS_DIR
|
||||
expect(mocks.writeFileSync).toHaveBeenCalledWith(
|
||||
path.join('/mock/videos', 'video_123.en.vtt'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// source and dest are technically same dir (but maybe different filenames if lang was parsed differently?)
|
||||
// In typical case: source = /videos/video_123.en.vtt, dest = /videos/video_123.en.vtt
|
||||
// Code says: if (sourceSubPath !== destSubPath) unlinkSync
|
||||
|
||||
// Using mock path.join, let's trace:
|
||||
// source = /mock/videos/video_123.en.vtt
|
||||
// dest = /mock/videos/video_123.en.vtt
|
||||
// So unlinkSync should NOT be called
|
||||
expect(mocks.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,30 +20,6 @@ describe('settingsValidationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVisitorModeRestrictions', () => {
|
||||
it('should allow everything if visitor mode disabled', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions({ visitorMode: false } as any, { websiteName: 'New' });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should block changes if visitor mode enabled', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions({ visitorMode: true } as any, { websiteName: 'New' });
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow turning off visitor mode', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions({ visitorMode: true } as any, { visitorMode: false });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow cloudflare settings update', () => {
|
||||
const result = settingsValidationService.checkVisitorModeRestrictions(
|
||||
{ visitorMode: true } as any,
|
||||
{ cloudflaredTunnelEnabled: true }
|
||||
);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSettings', () => {
|
||||
it('should merge defaults, existing, and new', () => {
|
||||
|
||||
@@ -27,10 +27,20 @@ vi.mock('../../db', () => {
|
||||
const selectFromLeftJoinWhereAll = vi.fn().mockReturnValue([]);
|
||||
const selectFromLeftJoinAll = vi.fn().mockReturnValue([]);
|
||||
|
||||
const updateSetRun = vi.fn();
|
||||
const updateSet = vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
run: updateSetRun,
|
||||
}),
|
||||
});
|
||||
const updateMock = vi.fn().mockReturnValue({
|
||||
set: updateSet,
|
||||
});
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: insertFn,
|
||||
update: vi.fn(),
|
||||
update: updateMock,
|
||||
delete: deleteMock,
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
@@ -55,7 +65,7 @@ vi.mock('../../db', () => {
|
||||
sqlite: {
|
||||
prepare: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
run: vi.fn(),
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
}),
|
||||
},
|
||||
downloads: {}, // Mock downloads table
|
||||
@@ -94,9 +104,16 @@ describe('StorageService', () => {
|
||||
run: vi.fn(),
|
||||
}),
|
||||
});
|
||||
(db.update as any).mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
run: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
(sqlite.prepare as any).mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
run: vi.fn(),
|
||||
run: vi.fn().mockReturnValue({ changes: 0 }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -588,7 +605,16 @@ describe('StorageService', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 2. getVideoById (inside loop)
|
||||
// 2. getCollections (called before getVideoById in deleteCollectionWithFiles)
|
||||
selectSpy.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 3. getVideoById (inside loop) - called for each video in collection
|
||||
selectSpy.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
@@ -597,8 +623,14 @@ describe('StorageService', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 3. getCollections (to check other collections) - called by findVideoFile
|
||||
// Will use the default db.select mock which returns empty array
|
||||
// 4. getCollections (called by findVideoFile inside moveAllFilesFromCollection)
|
||||
selectSpy.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 4. deleteCollection (inside deleteCollectionWithFiles) -> db.delete
|
||||
(db.delete as any).mockReturnValue({
|
||||
@@ -645,7 +677,13 @@ describe('StorageService', () => {
|
||||
} as any);
|
||||
|
||||
// 3. getCollections (called by findVideoFile in deleteVideo)
|
||||
// Will use the default db.select mock which returns empty array
|
||||
selectMock.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
leftJoin: vi.fn().mockReturnValue({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// 4. deleteVideo -> db.delete(videos)
|
||||
(db.delete as any).mockReturnValue({
|
||||
|
||||
@@ -21,6 +21,16 @@ describe('security', () => {
|
||||
it('should return false for traversal', () => {
|
||||
expect(security.validatePathWithinDirectory('/base/../other/file.txt', '/base')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle absolute paths correctly without duplication', () => {
|
||||
// Mock path.resolve to behave predictably for testing logic if needed,
|
||||
// but here we rely on the implementation fix.
|
||||
// This tests that if we pass an absolute path that is valid, it returns true.
|
||||
// The critical part is that it doesn't fail internally or double-resolve.
|
||||
const absPath = '/Users/user/project/backend/uploads/videos/test.mp4';
|
||||
const allowedDir = '/Users/user/project/backend/uploads/videos';
|
||||
expect(security.validatePathWithinDirectory(absPath, allowedDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUrl', () => {
|
||||
|
||||
@@ -28,10 +28,54 @@ export const uploadHook = async (
|
||||
throw new ValidationError("Invalid hook name", "name");
|
||||
}
|
||||
|
||||
// Scan for risk commands
|
||||
const riskCommand = scanForRiskCommands(req.file.path);
|
||||
if (riskCommand) {
|
||||
// Delete the file immediately
|
||||
require("fs").unlinkSync(req.file.path);
|
||||
throw new ValidationError(
|
||||
`Risk command detected: ${riskCommand}. Upload rejected.`,
|
||||
"file"
|
||||
);
|
||||
}
|
||||
|
||||
HookService.uploadHook(name, req.file.path);
|
||||
res.json(successMessage(`Hook ${name} uploaded successfully`));
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan file for risk commands
|
||||
*/
|
||||
const scanForRiskCommands = (filePath: string): string | null => {
|
||||
const fs = require("fs");
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// List of risky patterns
|
||||
// We use regex to match commands, trying to avoid false positives in comments if possible,
|
||||
// but for safety, even commented dangerous commands might be flagged or we just accept strictness.
|
||||
// A simple include check is safer for now.
|
||||
const riskyPatterns = [
|
||||
{ pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|-[a-zA-Z]*f[a-zA-Z]*\s+)*-?[rf][a-zA-Z]*\s+.*[\/\*]/, name: "rm -rf / (recursive delete)" }, // Matches rm -rf /, rm -fr *, etc roughly
|
||||
{ pattern: /mkfs/, name: "mkfs (format disk)" },
|
||||
{ pattern: /dd\s+if=/, name: "dd (disk write)" },
|
||||
{ pattern: /:[:\(\)\{\}\s|&]+;:/, name: "fork bomb" },
|
||||
{ pattern: />\s*\/dev\/sd/, name: "write to block device" },
|
||||
{ pattern: />\s*\/dev\/nvme/, name: "write to block device" },
|
||||
{ pattern: /mv\s+.*[\s\/]+\//, name: "mv to root" }, // deeply simplified, but mv / is dangerous
|
||||
{ pattern: /chmod\s+.*777\s+\//, name: "chmod 777 root" },
|
||||
{ pattern: /wget\s+http/, name: "wget (potential malware download)" },
|
||||
{ pattern: /curl\s+http/, name: "curl (potential malware download)" },
|
||||
];
|
||||
|
||||
for (const risk of riskyPatterns) {
|
||||
if (risk.pattern.test(content)) {
|
||||
return risk.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete hook script
|
||||
*/
|
||||
|
||||
188
backend/src/controllers/passkeyController.ts
Normal file
188
backend/src/controllers/passkeyController.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Request, Response } from "express";
|
||||
import { setAuthCookie } from "../services/authService";
|
||||
import * as passkeyService from "../services/passkeyService";
|
||||
|
||||
/**
|
||||
* Get all passkeys
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getPasskeys = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const passkeys = passkeyService.getPasskeys();
|
||||
// Don't send sensitive credential data to frontend
|
||||
const safePasskeys = passkeys.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
createdAt: p.createdAt,
|
||||
}));
|
||||
res.json({ passkeys: safePasskeys });
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if passkeys exist
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const checkPasskeysExist = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const passkeys = passkeyService.getPasskeys();
|
||||
res.json({ exists: passkeys.length > 0 });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get origin and RP ID from request
|
||||
*/
|
||||
function getOriginAndRPID(req: Request): { origin: string; rpID: string } {
|
||||
// Get origin from headers
|
||||
let origin = req.headers.origin;
|
||||
if (!origin && req.headers.referer) {
|
||||
// Extract origin from referer
|
||||
try {
|
||||
const refererUrl = new URL(req.headers.referer);
|
||||
origin = refererUrl.origin;
|
||||
} catch (e) {
|
||||
origin = req.headers.referer;
|
||||
}
|
||||
}
|
||||
if (!origin) {
|
||||
const protocol =
|
||||
req.headers["x-forwarded-proto"] || (req.secure ? "https" : "http");
|
||||
const host = req.headers.host || "localhost:5550";
|
||||
origin = `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
// Extract hostname for RP_ID
|
||||
let hostname = "localhost";
|
||||
try {
|
||||
const originUrl = new URL(origin as string);
|
||||
hostname = originUrl.hostname;
|
||||
} catch (e) {
|
||||
// Fallback: extract from host header
|
||||
hostname = req.headers.host?.split(":")[0] || "localhost";
|
||||
}
|
||||
|
||||
// RP_ID should be the domain name (without port)
|
||||
// For localhost/127.0.0.1, use 'localhost', otherwise use the full hostname
|
||||
const rpID =
|
||||
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
|
||||
? "localhost"
|
||||
: hostname;
|
||||
|
||||
return { origin: origin as string, rpID };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate registration options for creating a new passkey
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const generateRegistrationOptions = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const userName = req.body.userName || "MyTube User";
|
||||
const { origin, rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.generatePasskeyRegistrationOptions(
|
||||
userName,
|
||||
origin,
|
||||
rpID
|
||||
);
|
||||
res.json(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify and store a new passkey
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyRegistration = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { body, challenge } = req.body;
|
||||
if (!body || !challenge) {
|
||||
res.status(400).json({ error: "Missing body or challenge" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { origin, rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.verifyPasskeyRegistration(
|
||||
body,
|
||||
challenge,
|
||||
origin,
|
||||
rpID
|
||||
);
|
||||
|
||||
if (result.verified) {
|
||||
res.json({ success: true, passkey: result.passkey });
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: "Verification failed" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate authentication options for passkey login
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const generateAuthenticationOptions = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.generatePasskeyAuthenticationOptions(
|
||||
rpID
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: error instanceof Error ? error.message : "No passkeys available",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyAuthentication = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { body, challenge } = req.body;
|
||||
if (!body || !challenge) {
|
||||
res.status(400).json({ error: "Missing body or challenge" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { origin, rpID } = getOriginAndRPID(req);
|
||||
const result = await passkeyService.verifyPasskeyAuthentication(
|
||||
body,
|
||||
challenge,
|
||||
origin,
|
||||
rpID
|
||||
);
|
||||
|
||||
if (result.verified && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({ success: true, role: result.role });
|
||||
} else {
|
||||
res.status(401).json({ success: false, error: "Authentication failed" });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove all passkeys
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const removeAllPasskeys = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
passkeyService.removeAllPasskeys();
|
||||
res.json({ success: true });
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from "express";
|
||||
import { clearAuthCookie, setAuthCookie } from "../services/authService";
|
||||
import * as passwordService from "../services/passwordService";
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,7 @@ export const getPasswordEnabled = async (
|
||||
|
||||
/**
|
||||
* Verify password for authentication
|
||||
* @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyPassword = async (
|
||||
@@ -26,20 +28,111 @@ export const verifyPassword = async (
|
||||
|
||||
const result = await passwordService.verifyPassword(password);
|
||||
|
||||
if (result.success) {
|
||||
// Return format expected by frontend: { success: boolean }
|
||||
res.json({ success: true });
|
||||
if (result.success && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role
|
||||
});
|
||||
} else {
|
||||
// Return wait time information
|
||||
res.status(result.waitTime ? 429 : 401).json({
|
||||
// Return 200 OK to suppress browser console errors, but include status code and success: false
|
||||
const statusCode = result.waitTime ? 429 : 401;
|
||||
res.json({
|
||||
success: false,
|
||||
waitTime: result.waitTime,
|
||||
failedAttempts: result.failedAttempts,
|
||||
message: result.message,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify admin password for authentication
|
||||
* Only checks admin password, not visitor password
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyAdminPassword = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { password } = req.body;
|
||||
|
||||
const result = await passwordService.verifyAdminPassword(password);
|
||||
|
||||
if (result.success && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role
|
||||
});
|
||||
} else {
|
||||
const statusCode = result.waitTime ? 429 : 401;
|
||||
res.json({
|
||||
success: false,
|
||||
waitTime: result.waitTime,
|
||||
failedAttempts: result.failedAttempts,
|
||||
message: result.message,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify visitor password for authentication
|
||||
* Only checks visitor password, not admin password
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const verifyVisitorPassword = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { password } = req.body;
|
||||
|
||||
const result = await passwordService.verifyVisitorPassword(password);
|
||||
|
||||
if (result.success && result.token && result.role) {
|
||||
// Set HTTP-only cookie with authentication token
|
||||
setAuthCookie(res, result.token, result.role);
|
||||
// Return format expected by frontend: { success: boolean, role? }
|
||||
// Token is now in HTTP-only cookie, not in response body
|
||||
res.json({
|
||||
success: true,
|
||||
role: result.role
|
||||
});
|
||||
} else {
|
||||
const statusCode = result.waitTime ? 429 : 401;
|
||||
res.json({
|
||||
success: false,
|
||||
waitTime: result.waitTime,
|
||||
failedAttempts: result.failedAttempts,
|
||||
message: result.message,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the remaining cooldown time for password reset
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const getResetPasswordCooldown = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const remainingCooldown = passwordService.getResetPasswordCooldown();
|
||||
res.json({
|
||||
cooldown: remainingCooldown,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset password to a random 8-character string
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
@@ -57,3 +150,15 @@ export const resetPassword = async (
|
||||
"Password has been reset. Check backend logs for the new password.",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout endpoint - clears authentication cookies
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const logout = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
clearAuthCookie(res);
|
||||
res.json({ success: true, message: "Logged out successfully" });
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Request, Response } from "express";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import {
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
COLLECTIONS_DATA_PATH,
|
||||
STATUS_DATA_PATH,
|
||||
VIDEOS_DATA_PATH,
|
||||
} from "../config/paths";
|
||||
import { cloudflaredService } from "../services/cloudflaredService";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
@@ -37,9 +37,9 @@ export const getSettings = async (
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Do not send the hashed password to the frontend
|
||||
const { password, ...safeSettings } = mergedSettings;
|
||||
const { password, visitorPassword, ...safeSettings } = mergedSettings;
|
||||
// Return data directly for backward compatibility
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password });
|
||||
res.json({ ...safeSettings, isPasswordSet: !!password, isVisitorPasswordSet: !!visitorPassword });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,35 +124,9 @@ export const updateSettings = async (
|
||||
{}
|
||||
);
|
||||
|
||||
// Check visitor mode restrictions
|
||||
const visitorModeCheck =
|
||||
settingsValidationService.checkVisitorModeRestrictions(
|
||||
mergedSettings,
|
||||
newSettings
|
||||
);
|
||||
// Permission control is now handled by roleBasedSettingsMiddleware
|
||||
|
||||
if (!visitorModeCheck.allowed) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: visitorModeCheck.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle special case: visitorMode being set to true (already enabled)
|
||||
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
|
||||
// Only update visitorMode, ignore other changes
|
||||
const allowedSettings: Settings = {
|
||||
...mergedSettings,
|
||||
visitorMode: true,
|
||||
};
|
||||
storageService.saveSettings(allowedSettings);
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...allowedSettings, password: undefined },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
settingsValidationService.validateSettings(newSettings);
|
||||
@@ -253,7 +227,7 @@ export const updateSettings = async (
|
||||
// Return format expected by frontend: { success: true, settings: {...} }
|
||||
res.json({
|
||||
success: true,
|
||||
settings: { ...finalSettings, password: undefined },
|
||||
settings: { ...finalSettings, password: undefined, visitorPassword: undefined },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
|
||||
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||
import { getVideoDuration } from "../services/metadataService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { successResponse } from "../utils/response";
|
||||
@@ -95,11 +96,31 @@ export const refreshThumbnail = async (
|
||||
const validatedThumbnailPath = validateImagePath(thumbnailAbsolutePath);
|
||||
fs.ensureDirSync(path.dirname(validatedThumbnailPath));
|
||||
|
||||
// Calculate random timestamp
|
||||
let timestamp = "00:00:00";
|
||||
try {
|
||||
const duration = await getVideoDuration(validatedVideoPath);
|
||||
if (duration && duration > 0) {
|
||||
// Pick a random second, avoiding the very beginning and very end if possible
|
||||
// But for simplicity and to match request "random frame", valid random second is fine.
|
||||
// Let's ensure we don't go past the end.
|
||||
const randomSecond = Math.floor(Math.random() * duration);
|
||||
const hours = Math.floor(randomSecond / 3600);
|
||||
const minutes = Math.floor((randomSecond % 3600) / 60);
|
||||
const seconds = randomSecond % 60;
|
||||
timestamp = `${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to get video duration for random thumbnail, using default 00:00:00", err);
|
||||
}
|
||||
|
||||
// Generate thumbnail using execFileSafe to prevent command injection
|
||||
try {
|
||||
await execFileSafe("ffmpeg", [
|
||||
"-i", validatedVideoPath,
|
||||
"-ss", "00:00:00",
|
||||
"-ss", timestamp,
|
||||
"-vframes", "1",
|
||||
validatedThumbnailPath,
|
||||
"-y"
|
||||
|
||||
@@ -1,61 +1,71 @@
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { foreignKey, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
foreignKey,
|
||||
integer,
|
||||
primaryKey,
|
||||
sqliteTable,
|
||||
text,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const videos = sqliteTable('videos', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
author: text('author'),
|
||||
date: text('date'),
|
||||
source: text('source'),
|
||||
sourceUrl: text('source_url'),
|
||||
videoFilename: text('video_filename'),
|
||||
thumbnailFilename: text('thumbnail_filename'),
|
||||
videoPath: text('video_path'),
|
||||
thumbnailPath: text('thumbnail_path'),
|
||||
thumbnailUrl: text('thumbnail_url'),
|
||||
addedAt: text('added_at'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at'),
|
||||
partNumber: integer('part_number'),
|
||||
totalParts: integer('total_parts'),
|
||||
seriesTitle: text('series_title'),
|
||||
rating: integer('rating'),
|
||||
export const videos = sqliteTable("videos", {
|
||||
id: text("id").primaryKey(),
|
||||
title: text("title").notNull(),
|
||||
author: text("author"),
|
||||
date: text("date"),
|
||||
source: text("source"),
|
||||
sourceUrl: text("source_url"),
|
||||
videoFilename: text("video_filename"),
|
||||
thumbnailFilename: text("thumbnail_filename"),
|
||||
videoPath: text("video_path"),
|
||||
thumbnailPath: text("thumbnail_path"),
|
||||
thumbnailUrl: text("thumbnail_url"),
|
||||
addedAt: text("added_at"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at"),
|
||||
partNumber: integer("part_number"),
|
||||
totalParts: integer("total_parts"),
|
||||
seriesTitle: text("series_title"),
|
||||
rating: integer("rating"),
|
||||
// Additional fields that might be present
|
||||
description: text('description'),
|
||||
viewCount: integer('view_count'),
|
||||
duration: text('duration'),
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
progress: integer('progress'), // Playback progress in seconds
|
||||
fileSize: text('file_size'),
|
||||
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
|
||||
subtitles: text('subtitles'), // JSON stringified array of subtitle objects
|
||||
channelUrl: text('channel_url'), // Author channel URL for subscriptions
|
||||
visibility: integer('visibility').default(1), // 1 = visible, 0 = hidden
|
||||
description: text("description"),
|
||||
viewCount: integer("view_count"),
|
||||
duration: text("duration"),
|
||||
tags: text("tags"), // JSON stringified array of strings
|
||||
progress: integer("progress"), // Playback progress in seconds
|
||||
fileSize: text("file_size"),
|
||||
lastPlayedAt: integer("last_played_at"), // Timestamp when video was last played
|
||||
subtitles: text("subtitles"), // JSON stringified array of subtitle objects
|
||||
channelUrl: text("channel_url"), // Author channel URL for subscriptions
|
||||
visibility: integer("visibility").default(1), // 1 = visible, 0 = hidden
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
title: text('title'), // Keeping for backward compatibility/alias
|
||||
createdAt: text('created_at').notNull(),
|
||||
updatedAt: text('updated_at'),
|
||||
export const collections = sqliteTable("collections", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
title: text("title"), // Keeping for backward compatibility/alias
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at"),
|
||||
});
|
||||
|
||||
export const collectionVideos = sqliteTable('collection_videos', {
|
||||
collectionId: text('collection_id').notNull(),
|
||||
videoId: text('video_id').notNull(),
|
||||
order: integer('order'), // To maintain order if needed
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.collectionId, t.videoId] }),
|
||||
collectionFk: foreignKey({
|
||||
columns: [t.collectionId],
|
||||
foreignColumns: [collections.id],
|
||||
}).onDelete('cascade'),
|
||||
videoFk: foreignKey({
|
||||
columns: [t.videoId],
|
||||
foreignColumns: [videos.id],
|
||||
}).onDelete('cascade'),
|
||||
}));
|
||||
export const collectionVideos = sqliteTable(
|
||||
"collection_videos",
|
||||
{
|
||||
collectionId: text("collection_id").notNull(),
|
||||
videoId: text("video_id").notNull(),
|
||||
order: integer("order"), // To maintain order if needed
|
||||
},
|
||||
(t) => ({
|
||||
pk: primaryKey({ columns: [t.collectionId, t.videoId] }),
|
||||
collectionFk: foreignKey({
|
||||
columns: [t.collectionId],
|
||||
foreignColumns: [collections.id],
|
||||
}).onDelete("cascade"),
|
||||
videoFk: foreignKey({
|
||||
columns: [t.videoId],
|
||||
foreignColumns: [videos.id],
|
||||
}).onDelete("cascade"),
|
||||
})
|
||||
);
|
||||
|
||||
// Relations
|
||||
export const videosRelations = relations(videos, ({ many }) => ({
|
||||
@@ -66,94 +76,100 @@ export const collectionsRelations = relations(collections, ({ many }) => ({
|
||||
videos: many(collectionVideos),
|
||||
}));
|
||||
|
||||
export const collectionVideosRelations = relations(collectionVideos, ({ one }) => ({
|
||||
collection: one(collections, {
|
||||
fields: [collectionVideos.collectionId],
|
||||
references: [collections.id],
|
||||
}),
|
||||
video: one(videos, {
|
||||
fields: [collectionVideos.videoId],
|
||||
references: [videos.id],
|
||||
}),
|
||||
}));
|
||||
export const collectionVideosRelations = relations(
|
||||
collectionVideos,
|
||||
({ one }) => ({
|
||||
collection: one(collections, {
|
||||
fields: [collectionVideos.collectionId],
|
||||
references: [collections.id],
|
||||
}),
|
||||
video: one(videos, {
|
||||
fields: [collectionVideos.videoId],
|
||||
references: [videos.id],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value').notNull(), // JSON stringified value
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(), // JSON stringified value
|
||||
});
|
||||
|
||||
export const downloads = sqliteTable('downloads', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
timestamp: integer('timestamp'),
|
||||
filename: text('filename'),
|
||||
totalSize: text('total_size'),
|
||||
downloadedSize: text('downloaded_size'),
|
||||
progress: integer('progress'), // Using integer for percentage (0-100) or similar
|
||||
speed: text('speed'),
|
||||
status: text('status').notNull().default('active'), // 'active' or 'queued'
|
||||
sourceUrl: text('source_url'),
|
||||
type: text('type'),
|
||||
export const downloads = sqliteTable("downloads", {
|
||||
id: text("id").primaryKey(),
|
||||
title: text("title").notNull(),
|
||||
timestamp: integer("timestamp"),
|
||||
filename: text("filename"),
|
||||
totalSize: text("total_size"),
|
||||
downloadedSize: text("downloaded_size"),
|
||||
progress: integer("progress"), // Using integer for percentage (0-100) or similar
|
||||
speed: text("speed"),
|
||||
status: text("status").notNull().default("active"), // 'active' or 'queued'
|
||||
sourceUrl: text("source_url"),
|
||||
type: text("type"),
|
||||
});
|
||||
|
||||
export const downloadHistory = sqliteTable('download_history', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
author: text('author'),
|
||||
sourceUrl: text('source_url'),
|
||||
finishedAt: integer('finished_at').notNull(), // Timestamp
|
||||
status: text('status').notNull(), // 'success', 'failed', 'skipped', or 'deleted'
|
||||
error: text('error'), // Error message if failed
|
||||
videoPath: text('video_path'), // Path to video file if successful
|
||||
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
|
||||
totalSize: text('total_size'),
|
||||
videoId: text('video_id'), // Reference to video for skipped items
|
||||
downloadedAt: integer('downloaded_at'), // Original download timestamp for deleted items
|
||||
deletedAt: integer('deleted_at'), // Deletion timestamp for deleted items
|
||||
export const downloadHistory = sqliteTable("download_history", {
|
||||
id: text("id").primaryKey(),
|
||||
title: text("title").notNull(),
|
||||
author: text("author"),
|
||||
sourceUrl: text("source_url"),
|
||||
finishedAt: integer("finished_at").notNull(), // Timestamp
|
||||
status: text("status").notNull(), // 'success', 'failed', 'skipped', or 'deleted'
|
||||
error: text("error"), // Error message if failed
|
||||
videoPath: text("video_path"), // Path to video file if successful
|
||||
thumbnailPath: text("thumbnail_path"), // Path to thumbnail if successful
|
||||
totalSize: text("total_size"),
|
||||
videoId: text("video_id"), // Reference to video for skipped items
|
||||
downloadedAt: integer("downloaded_at"), // Original download timestamp for deleted items
|
||||
deletedAt: integer("deleted_at"), // Deletion timestamp for deleted items
|
||||
});
|
||||
|
||||
export const subscriptions = sqliteTable('subscriptions', {
|
||||
id: text('id').primaryKey(),
|
||||
author: text('author').notNull(),
|
||||
authorUrl: text('author_url').notNull(),
|
||||
interval: integer('interval').notNull(), // Check interval in minutes
|
||||
lastVideoLink: text('last_video_link'),
|
||||
lastCheck: integer('last_check'), // Timestamp
|
||||
downloadCount: integer('download_count').default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
platform: text('platform').default('YouTube'),
|
||||
export const subscriptions = sqliteTable("subscriptions", {
|
||||
id: text("id").primaryKey(),
|
||||
author: text("author").notNull(),
|
||||
authorUrl: text("author_url").notNull(),
|
||||
interval: integer("interval").notNull(), // Check interval in minutes
|
||||
lastVideoLink: text("last_video_link"),
|
||||
lastCheck: integer("last_check"), // Timestamp
|
||||
downloadCount: integer("download_count").default(0),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
platform: text("platform").default("YouTube"),
|
||||
});
|
||||
|
||||
// Track downloaded video IDs to prevent re-downloading
|
||||
export const videoDownloads = sqliteTable('video_downloads', {
|
||||
id: text('id').primaryKey(), // Unique identifier
|
||||
sourceVideoId: text('source_video_id').notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.)
|
||||
sourceUrl: text('source_url').notNull(), // Original source URL
|
||||
platform: text('platform').notNull(), // YouTube, Bilibili, MissAV, etc.
|
||||
videoId: text('video_id'), // Reference to local video ID (null if deleted)
|
||||
title: text('title'), // Video title for display
|
||||
author: text('author'), // Video author
|
||||
status: text('status').notNull().default('exists'), // 'exists' or 'deleted'
|
||||
downloadedAt: integer('downloaded_at').notNull(), // Timestamp of first download
|
||||
deletedAt: integer('deleted_at'), // Timestamp when video was deleted (nullable)
|
||||
export const videoDownloads = sqliteTable("video_downloads", {
|
||||
id: text("id").primaryKey(), // Unique identifier
|
||||
sourceVideoId: text("source_video_id").notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.)
|
||||
sourceUrl: text("source_url").notNull(), // Original source URL
|
||||
platform: text("platform").notNull(), // YouTube, Bilibili, MissAV, etc.
|
||||
videoId: text("video_id"), // Reference to local video ID (null if deleted)
|
||||
title: text("title"), // Video title for display
|
||||
author: text("author"), // Video author
|
||||
status: text("status").notNull().default("exists"), // 'exists' or 'deleted'
|
||||
downloadedAt: integer("downloaded_at").notNull(), // Timestamp of first download
|
||||
deletedAt: integer("deleted_at"), // Timestamp when video was deleted (nullable)
|
||||
});
|
||||
|
||||
// Track continuous download tasks for downloading all previous videos from an author
|
||||
export const continuousDownloadTasks = sqliteTable('continuous_download_tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
subscriptionId: text('subscription_id'), // Reference to subscription (nullable if subscription deleted)
|
||||
collectionId: text('collection_id'), // Reference to collection (nullable, for playlist tasks)
|
||||
authorUrl: text('author_url').notNull(),
|
||||
author: text('author').notNull(),
|
||||
platform: text('platform').notNull(), // YouTube, Bilibili, etc.
|
||||
status: text('status').notNull().default('active'), // 'active', 'paused', 'completed', 'cancelled'
|
||||
totalVideos: integer('total_videos').default(0), // Total videos found
|
||||
downloadedCount: integer('downloaded_count').default(0), // Number of videos downloaded
|
||||
skippedCount: integer('skipped_count').default(0), // Number of videos skipped (already downloaded)
|
||||
failedCount: integer('failed_count').default(0), // Number of videos that failed
|
||||
currentVideoIndex: integer('current_video_index').default(0), // Current video being processed
|
||||
createdAt: integer('created_at').notNull(), // Timestamp when task was created
|
||||
updatedAt: integer('updated_at'), // Timestamp of last update
|
||||
completedAt: integer('completed_at'), // Timestamp when task completed
|
||||
error: text('error'), // Error message if task failed
|
||||
});
|
||||
export const continuousDownloadTasks = sqliteTable(
|
||||
"continuous_download_tasks",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
subscriptionId: text("subscription_id"), // Reference to subscription (nullable if subscription deleted)
|
||||
collectionId: text("collection_id"), // Reference to collection (nullable, for playlist tasks)
|
||||
authorUrl: text("author_url").notNull(),
|
||||
author: text("author").notNull(),
|
||||
platform: text("platform").notNull(), // YouTube, Bilibili, etc.
|
||||
status: text("status").notNull().default("active"), // 'active', 'paused', 'completed', 'cancelled'
|
||||
totalVideos: integer("total_videos").default(0), // Total videos found
|
||||
downloadedCount: integer("downloaded_count").default(0), // Number of videos downloaded
|
||||
skippedCount: integer("skipped_count").default(0), // Number of videos skipped (already downloaded)
|
||||
failedCount: integer("failed_count").default(0), // Number of videos that failed
|
||||
currentVideoIndex: integer("current_video_index").default(0), // Current video being processed
|
||||
createdAt: integer("created_at").notNull(), // Timestamp when task was created
|
||||
updatedAt: integer("updated_at"), // Timestamp of last update
|
||||
completedAt: integer("completed_at"), // Timestamp when task completed
|
||||
error: text("error"), // Error message if task failed
|
||||
}
|
||||
);
|
||||
|
||||
49
backend/src/middleware/authMiddleware.ts
Normal file
49
backend/src/middleware/authMiddleware.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { getAuthCookieName, UserPayload, verifyToken } from "../services/authService";
|
||||
|
||||
// Extend Express Request type to include user property
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to verify JWT token and attach user to request
|
||||
* Checks both HTTP-only cookies (preferred) and Authorization header (for backward compatibility)
|
||||
* Does NOT block requests if token is missing/invalid, just leaves req.user undefined
|
||||
* Blocking logic should be handled by specific route guards or role-based middleware
|
||||
*/
|
||||
export const authMiddleware = (
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
// First, try to get token from HTTP-only cookie (preferred method)
|
||||
const cookieName = getAuthCookieName();
|
||||
const tokenFromCookie = req.cookies?.[cookieName];
|
||||
|
||||
if (tokenFromCookie) {
|
||||
const decoded = verifyToken(tokenFromCookie);
|
||||
if (decoded) {
|
||||
req.user = decoded;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Authorization header for backward compatibility
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||
const token = authHeader.split(" ")[1];
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (decoded) {
|
||||
req.user = decoded;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
64
backend/src/middleware/roleBasedAuthMiddleware.ts
Normal file
64
backend/src/middleware/roleBasedAuthMiddleware.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* Middleware to enforce role-based access control
|
||||
* Visitors (userRole === 'visitor') are restricted to read-only operations
|
||||
* Admins (userRole === 'admin') have full access
|
||||
* Unauthenticated users are handled by loginEnabled setting
|
||||
*/
|
||||
export const roleBasedAuthMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
// If user is Admin, allow all requests
|
||||
if (req.user?.role === "admin") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is Visitor, restrict to read-only
|
||||
if (req.user?.role === "visitor") {
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow authentication-related POST requests
|
||||
if (req.method === "POST") {
|
||||
// Allow verify-password requests (including verify-admin-password and verify-visitor-password)
|
||||
if (
|
||||
req.path.includes("/verify-password") ||
|
||||
req.url.includes("/verify-password") ||
|
||||
req.path.includes("/verify-admin-password") ||
|
||||
req.url.includes("/verify-admin-password") ||
|
||||
req.path.includes("/verify-visitor-password") ||
|
||||
req.url.includes("/verify-visitor-password")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow passkey authentication
|
||||
if (
|
||||
req.path.includes("/settings/passkeys/authenticate") ||
|
||||
req.url.includes("/settings/passkeys/authenticate")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Block all other write operations (POST, PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor role: Write operations are not allowed. Read-only access only.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For unauthenticated users, allow the request to proceed
|
||||
// (loginEnabled check and other auth logic will handle it)
|
||||
next();
|
||||
};
|
||||
98
backend/src/middleware/roleBasedSettingsMiddleware.ts
Normal file
98
backend/src/middleware/roleBasedSettingsMiddleware.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
|
||||
/**
|
||||
* Middleware specifically for settings routes with role-based access control
|
||||
* Visitors can only read settings and update CloudFlare tunnel settings
|
||||
* Admins have full access to all settings
|
||||
*/
|
||||
export const roleBasedSettingsMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
// If user is Admin, allow all requests
|
||||
if (req.user?.role === "admin") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is Visitor, restrict to read-only and CloudFlare updates
|
||||
if (req.user?.role === "visitor") {
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// For POST requests, check if it's authentication or CloudFlare settings
|
||||
if (req.method === "POST") {
|
||||
// Allow verify-password requests (including verify-admin-password and verify-visitor-password)
|
||||
if (
|
||||
req.path.includes("/verify-password") ||
|
||||
req.url.includes("/verify-password") ||
|
||||
req.path.includes("/verify-admin-password") ||
|
||||
req.url.includes("/verify-admin-password") ||
|
||||
req.path.includes("/verify-visitor-password") ||
|
||||
req.url.includes("/verify-visitor-password")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow passkey authentication
|
||||
if (
|
||||
req.path.includes("/passkeys/authenticate") ||
|
||||
req.url.includes("/passkeys/authenticate")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow logout endpoint
|
||||
if (
|
||||
req.path.includes("/logout") ||
|
||||
req.url.includes("/logout")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
|
||||
// Allow CloudFlare tunnel settings updates (read-only access mechanism)
|
||||
const isOnlyCloudflareUpdate =
|
||||
(body.cloudflaredTunnelEnabled !== undefined ||
|
||||
body.cloudflaredToken !== undefined) &&
|
||||
Object.keys(body).every(
|
||||
(key) =>
|
||||
key === "cloudflaredTunnelEnabled" ||
|
||||
key === "cloudflaredToken"
|
||||
);
|
||||
|
||||
if (isOnlyCloudflareUpdate) {
|
||||
// Allow CloudFlare settings updates
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
"Visitor role: Only reading settings and updating CloudFlare settings is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor role: Write operations are not allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For unauthenticated users, allow the request to proceed
|
||||
// (loginEnabled check and other auth logic will handle it)
|
||||
next();
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Middleware to block write operations when visitor mode is enabled
|
||||
* Only allows disabling visitor mode (POST /settings with visitorMode: false)
|
||||
*/
|
||||
export const visitorModeMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const settings = storageService.getSettings();
|
||||
const visitorMode = settings.visitorMode === true;
|
||||
|
||||
if (!visitorMode) {
|
||||
// Visitor mode is not enabled, allow all requests
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the request is trying to disable visitor mode or verify password
|
||||
if (req.method === "POST") {
|
||||
const body = req.body || {};
|
||||
|
||||
// Allow verify-password requests
|
||||
// Check path for verify-password (assuming mounted on /api or similar)
|
||||
if (req.path.includes("/verify-password") || req.url.includes("/verify-password")) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Only disabling visitor mode is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Write operations are not allowed.",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import * as storageService from "../services/storageService";
|
||||
|
||||
/**
|
||||
* Middleware specifically for settings routes
|
||||
* Allows disabling visitor mode even when visitor mode is enabled
|
||||
*/
|
||||
export const visitorModeSettingsMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const settings = storageService.getSettings();
|
||||
const visitorMode = settings.visitorMode === true;
|
||||
|
||||
if (!visitorMode) {
|
||||
// Visitor mode is not enabled, allow all requests
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Visitor mode is enabled
|
||||
// Allow GET requests (read-only)
|
||||
if (req.method === "GET") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// For POST requests, check if it's trying to disable visitor mode, verify password, or update CloudFlare settings
|
||||
if (req.method === "POST") {
|
||||
// Allow verify-password requests
|
||||
if (
|
||||
req.path.includes("/verify-password") ||
|
||||
req.url.includes("/verify-password")
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const body = req.body || {};
|
||||
// Check if the request is trying to disable visitor mode
|
||||
if (body.visitorMode === false) {
|
||||
// Allow disabling visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow CloudFlare tunnel settings updates (read-only access mechanism, doesn't violate visitor mode)
|
||||
const isOnlyCloudflareUpdate =
|
||||
(body.cloudflaredTunnelEnabled !== undefined ||
|
||||
body.cloudflaredToken !== undefined) &&
|
||||
Object.keys(body).every(
|
||||
(key) =>
|
||||
key === "cloudflaredTunnelEnabled" ||
|
||||
key === "cloudflaredToken" ||
|
||||
key === "visitorMode"
|
||||
);
|
||||
|
||||
if (isOnlyCloudflareUpdate) {
|
||||
// Allow CloudFlare settings updates even in visitor mode
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other settings updates
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error:
|
||||
"Visitor mode is enabled. Only disabling visitor mode or updating CloudFlare settings is allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all other write operations (PUT, DELETE, PATCH)
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Visitor mode is enabled. Write operations are not allowed.",
|
||||
});
|
||||
};
|
||||
@@ -2,32 +2,47 @@ import express from "express";
|
||||
import multer from "multer";
|
||||
import os from "os";
|
||||
import {
|
||||
checkCookies,
|
||||
deleteCookies,
|
||||
uploadCookies,
|
||||
checkCookies,
|
||||
deleteCookies,
|
||||
uploadCookies,
|
||||
} from "../controllers/cookieController";
|
||||
import {
|
||||
cleanupBackupDatabases,
|
||||
exportDatabase,
|
||||
getLastBackupInfo,
|
||||
importDatabase,
|
||||
restoreFromLastBackup,
|
||||
cleanupBackupDatabases,
|
||||
exportDatabase,
|
||||
getLastBackupInfo,
|
||||
importDatabase,
|
||||
restoreFromLastBackup,
|
||||
} from "../controllers/databaseBackupController";
|
||||
import {
|
||||
deleteHook,
|
||||
getHookStatus,
|
||||
uploadHook,
|
||||
deleteHook,
|
||||
getHookStatus,
|
||||
uploadHook,
|
||||
} from "../controllers/hookController";
|
||||
import {
|
||||
getPasswordEnabled
|
||||
getPasswordEnabled,
|
||||
getResetPasswordCooldown,
|
||||
logout,
|
||||
resetPassword,
|
||||
verifyPassword,
|
||||
verifyAdminPassword,
|
||||
verifyVisitorPassword,
|
||||
} from "../controllers/passwordController";
|
||||
import {
|
||||
deleteLegacyData,
|
||||
formatFilenames,
|
||||
getCloudflaredStatus,
|
||||
getSettings,
|
||||
migrateData,
|
||||
updateSettings,
|
||||
checkPasskeysExist,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
getPasskeys,
|
||||
removeAllPasskeys,
|
||||
verifyAuthentication,
|
||||
verifyRegistration,
|
||||
} from "../controllers/passkeyController";
|
||||
import {
|
||||
deleteLegacyData,
|
||||
formatFilenames,
|
||||
getCloudflaredStatus,
|
||||
getSettings,
|
||||
migrateData,
|
||||
updateSettings,
|
||||
} from "../controllers/settingsController";
|
||||
import { asyncHandler } from "../middleware/errorHandler";
|
||||
|
||||
@@ -43,6 +58,21 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
|
||||
|
||||
// Password routes
|
||||
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
|
||||
router.get("/reset-password-cooldown", asyncHandler(getResetPasswordCooldown));
|
||||
router.post("/verify-password", asyncHandler(verifyPassword)); // Deprecated, use verify-admin-password or verify-visitor-password
|
||||
router.post("/verify-admin-password", asyncHandler(verifyAdminPassword));
|
||||
router.post("/verify-visitor-password", asyncHandler(verifyVisitorPassword));
|
||||
router.post("/reset-password", asyncHandler(resetPassword));
|
||||
router.post("/logout", asyncHandler(logout));
|
||||
|
||||
// Passkey routes
|
||||
router.get("/passkeys", asyncHandler(getPasskeys));
|
||||
router.get("/passkeys/exists", asyncHandler(checkPasskeysExist));
|
||||
router.post("/passkeys/register", asyncHandler(generateRegistrationOptions));
|
||||
router.post("/passkeys/register/verify", asyncHandler(verifyRegistration));
|
||||
router.post("/passkeys/authenticate", asyncHandler(generateAuthenticationOptions));
|
||||
router.post("/passkeys/authenticate/verify", asyncHandler(verifyAuthentication));
|
||||
router.delete("/passkeys", asyncHandler(removeAllPasskeys));
|
||||
|
||||
// ... existing imports ...
|
||||
|
||||
@@ -56,11 +86,7 @@ router.post("/delete-cookies", asyncHandler(deleteCookies));
|
||||
router.get("/check-cookies", asyncHandler(checkCookies));
|
||||
|
||||
// Hook routes
|
||||
router.post(
|
||||
"/hooks/:name",
|
||||
upload.single("file"),
|
||||
asyncHandler(uploadHook)
|
||||
);
|
||||
router.post("/hooks/:name", upload.single("file"), asyncHandler(uploadHook));
|
||||
router.delete("/hooks/:name", asyncHandler(deleteHook));
|
||||
router.get("/hooks/status", asyncHandler(getHookStatus));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
import cookieParser from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
@@ -12,8 +13,9 @@ import {
|
||||
VIDEOS_DIR,
|
||||
} from "./config/paths";
|
||||
import { runMigrations } from "./db/migrate";
|
||||
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
|
||||
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
|
||||
import { authMiddleware } from "./middleware/authMiddleware";
|
||||
import { roleBasedAuthMiddleware } from "./middleware/roleBasedAuthMiddleware";
|
||||
import { roleBasedSettingsMiddleware } from "./middleware/roleBasedSettingsMiddleware";
|
||||
import apiRoutes from "./routes/api";
|
||||
import settingsRoutes from "./routes/settingsRoutes";
|
||||
import { cloudflaredService } from "./services/cloudflaredService";
|
||||
@@ -38,7 +40,13 @@ const PORT = process.env.PORT || 5551;
|
||||
app.disable("x-powered-by");
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
// Configure CORS to allow credentials for HTTP-only cookies
|
||||
app.use(cors({
|
||||
origin: true, // Allow requests from any origin (can be restricted in production)
|
||||
credentials: true, // Required for HTTP-only cookies
|
||||
}));
|
||||
// Parse cookies
|
||||
app.use(cookieParser());
|
||||
// Increase body size limits for large file uploads (10GB)
|
||||
app.use(express.json({ limit: "100gb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "100gb" }));
|
||||
@@ -243,10 +251,12 @@ const startServer = async () => {
|
||||
);
|
||||
|
||||
// API Routes
|
||||
// Apply visitor mode middleware to all API routes
|
||||
app.use("/api", visitorModeMiddleware, apiRoutes);
|
||||
// Use separate middleware for settings that allows disabling visitor mode
|
||||
app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes);
|
||||
// Apply auth middleware to all API routes
|
||||
app.use("/api", authMiddleware);
|
||||
// Apply role-based access control middleware to all API routes
|
||||
app.use("/api", roleBasedAuthMiddleware, apiRoutes);
|
||||
// Use separate middleware for settings with role-based access control
|
||||
app.use("/api/settings", roleBasedSettingsMiddleware, settingsRoutes);
|
||||
|
||||
// SPA Fallback for Frontend
|
||||
app.get("*", (req, res) => {
|
||||
|
||||
295
backend/src/services/__tests__/passkeyService.test.ts
Normal file
295
backend/src/services/__tests__/passkeyService.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
generatePasskeyAuthenticationOptions,
|
||||
generatePasskeyRegistrationOptions,
|
||||
removeAllPasskeys,
|
||||
verifyPasskeyAuthentication,
|
||||
verifyPasskeyRegistration
|
||||
} from "../passkeyService";
|
||||
import * as storageService from "../storageService";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../storageService", () => ({
|
||||
getSettings: vi.fn(),
|
||||
saveSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@simplewebauthn/server", () => ({
|
||||
generateRegistrationOptions: vi.fn(),
|
||||
verifyRegistrationResponse: vi.fn(),
|
||||
generateAuthenticationOptions: vi.fn(),
|
||||
verifyAuthenticationResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../authService", () => ({
|
||||
generateToken: vi.fn(() => "mock-token"),
|
||||
}));
|
||||
|
||||
describe("passkeyService", () => {
|
||||
const mockPasskey = {
|
||||
credentialID: "mock-credential-id",
|
||||
credentialPublicKey: "mock-public-key",
|
||||
counter: 0,
|
||||
transports: ["internal"],
|
||||
id: "mock-credential-id",
|
||||
name: "Passkey 1",
|
||||
createdAt: "2023-01-01T00:00:00.000Z",
|
||||
rpID: "localhost",
|
||||
origin: "http://localhost:5550",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
(storageService.getSettings as any).mockReturnValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("generatePasskeyRegistrationOptions", () => {
|
||||
it("should generate registration options correctly", async () => {
|
||||
const mockOptions = { challenge: "mock-challenge" };
|
||||
(generateRegistrationOptions as any).mockResolvedValue(mockOptions);
|
||||
|
||||
const result = await generatePasskeyRegistrationOptions("testuser");
|
||||
|
||||
expect(generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userName: "testuser",
|
||||
attestationType: "none",
|
||||
authenticatorSelection: expect.objectContaining({
|
||||
authenticatorAttachment: "platform",
|
||||
userVerification: "preferred",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
options: mockOptions,
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
});
|
||||
|
||||
it("should exclude existing credentials", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
(generateRegistrationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
await generatePasskeyRegistrationOptions("testuser");
|
||||
|
||||
expect(generateRegistrationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
excludeCredentials: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String), // In the real code it's base64url encoded
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPasskeyRegistration", () => {
|
||||
it("should verify and store a new passkey correctly (NO double encoding)", async () => {
|
||||
const mockVerification = {
|
||||
verified: true,
|
||||
registrationInfo: {
|
||||
credential: {
|
||||
id: "raw-credential-id-from-browser", // Assume simplewebauthn returns this as string/base64url
|
||||
publicKey: Buffer.from("mock-public-key"),
|
||||
counter: 0,
|
||||
transports: ["internal"],
|
||||
},
|
||||
},
|
||||
};
|
||||
(verifyRegistrationResponse as any).mockResolvedValue(mockVerification);
|
||||
|
||||
const result = await verifyPasskeyRegistration(
|
||||
{ response: {}, name: "My Passkey" },
|
||||
"mock-challenge"
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.passkey?.credentialID).toBe("raw-credential-id-from-browser"); // MUST NOT BE DOUBLE ENCODED
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "raw-credential-id-from-browser",
|
||||
name: "My Passkey",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle verification failure", async () => {
|
||||
(verifyRegistrationResponse as any).mockResolvedValue({ verified: false });
|
||||
|
||||
const result = await verifyPasskeyRegistration({}, "mock-challenge");
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(storageService.saveSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePasskeyAuthenticationOptions", () => {
|
||||
it("should generate authentication options with correct allowCredentials (NO double encoding)", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
(generateAuthenticationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
const result = await generatePasskeyAuthenticationOptions("localhost");
|
||||
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowCredentials: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "mock-credential-id", // MUST MATCH STORED ID EXACTLY
|
||||
transports: ["internal"],
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
options: { challenge: "mock-challenge" },
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter passkeys by RP ID", async () => {
|
||||
const passkey1 = { ...mockPasskey, rpID: "domain1.com", id: "id1", credentialID: "id1" };
|
||||
const passkey2 = { ...mockPasskey, rpID: "domain2.com", id: "id2", credentialID: "id2" };
|
||||
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [passkey1, passkey2],
|
||||
});
|
||||
(generateAuthenticationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
await generatePasskeyAuthenticationOptions("domain1.com");
|
||||
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowCredentials: [
|
||||
expect.objectContaining({ id: "id1" })
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should include legacy passkeys (no rpID stored) as fallback", async () => {
|
||||
const legacyPasskey = { ...mockPasskey, rpID: undefined, id: "legacy", credentialID: "legacy" };
|
||||
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [legacyPasskey],
|
||||
});
|
||||
(generateAuthenticationOptions as any).mockResolvedValue({
|
||||
challenge: "mock-challenge",
|
||||
});
|
||||
|
||||
await generatePasskeyAuthenticationOptions("any-domain.com");
|
||||
|
||||
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowCredentials: [
|
||||
expect.objectContaining({ id: "legacy" })
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if no passkeys registered", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({});
|
||||
await expect(generatePasskeyAuthenticationOptions()).rejects.toThrow(
|
||||
"No passkeys registered"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyPasskeyAuthentication", () => {
|
||||
it("should verify authentication successfully", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
const mockVerification = {
|
||||
verified: true,
|
||||
authenticationInfo: { newCounter: 1 },
|
||||
};
|
||||
(verifyAuthenticationResponse as any).mockResolvedValue(mockVerification);
|
||||
|
||||
const result = await verifyPasskeyAuthentication(
|
||||
{ id: "mock-credential-id", response: {} },
|
||||
"mock-challenge"
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(true);
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "mock-credential-id",
|
||||
counter: 1
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
passkeys: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
credentialID: "mock-credential-id",
|
||||
counter: 1
|
||||
})
|
||||
])
|
||||
})
|
||||
);
|
||||
expect(result.token).toBe("mock-token");
|
||||
expect(result.role).toBe("admin");
|
||||
});
|
||||
|
||||
it("should fail if passkey not found", async () => {
|
||||
(storageService.getSettings as any).mockReturnValue({
|
||||
passkeys: [mockPasskey],
|
||||
});
|
||||
|
||||
const result = await verifyPasskeyAuthentication(
|
||||
{ id: "unknown-id", response: {} },
|
||||
"mock-challenge"
|
||||
);
|
||||
|
||||
expect(result.verified).toBe(false);
|
||||
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeAllPasskeys", () => {
|
||||
it("should remove all passkeys", () => {
|
||||
removeAllPasskeys();
|
||||
expect(storageService.saveSettings).toHaveBeenCalledWith({
|
||||
passkeys: []
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
92
backend/src/services/authService.ts
Normal file
92
backend/src/services/authService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "default_development_secret_do_not_use_in_production";
|
||||
const JWT_EXPIRES_IN = "24h";
|
||||
const COOKIE_NAME = "mytube_auth_token";
|
||||
|
||||
export interface UserPayload {
|
||||
role: "admin" | "visitor";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a user
|
||||
*/
|
||||
export const generateToken = (payload: UserPayload): string => {
|
||||
return jwt.sign({ ...payload, id: payload.id || uuidv4() }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export const verifyToken = (token: string): UserPayload | null => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set HTTP-only cookie with authentication token
|
||||
* This is more secure than storing tokens in localStorage as it's not accessible to JavaScript
|
||||
*/
|
||||
export const setAuthCookie = (res: Response, token: string, role: "admin" | "visitor"): void => {
|
||||
// Calculate expiration time (24 hours in milliseconds)
|
||||
const maxAge = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Set HTTP-only cookie (not accessible to JavaScript, preventing XSS attacks)
|
||||
// SameSite=Strict provides CSRF protection
|
||||
// Secure flag should be set in production (HTTPS only)
|
||||
// Set HTTP-only cookie (not accessible to JavaScript, preventing XSS attacks)
|
||||
// SameSite=Lax allows for better usability while maintaining CSRF protection
|
||||
// Secure flag is optional (env var) to allow potential HTTP usage in private networks
|
||||
const isSecure = process.env.SECURE_COOKIES === "true";
|
||||
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true, // Not accessible to JavaScript
|
||||
secure: isSecure, // Only sent over HTTPS if explicitly configured
|
||||
sameSite: "lax", // Better persistence across navigations
|
||||
maxAge: maxAge, // 24 hours
|
||||
path: "/", // Available for all paths
|
||||
});
|
||||
|
||||
// Also set role in a separate cookie (non-HTTP-only for frontend to read)
|
||||
res.cookie("mytube_role", role, {
|
||||
httpOnly: false, // Frontend needs to read this
|
||||
secure: isSecure,
|
||||
sameSite: "lax",
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear authentication cookies
|
||||
*/
|
||||
export const clearAuthCookie = (res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: process.env.SECURE_COOKIES === "true",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
res.clearCookie("mytube_role", {
|
||||
httpOnly: false,
|
||||
secure: process.env.SECURE_COOKIES === "true",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cookie name for authentication token
|
||||
*/
|
||||
export const getAuthCookieName = (): string => {
|
||||
return COOKIE_NAME;
|
||||
};
|
||||
@@ -3,8 +3,8 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { DownloadCancelledError } from "../../errors/DownloadErrors";
|
||||
import {
|
||||
isCancellationError,
|
||||
isDownloadActive,
|
||||
isCancellationError,
|
||||
isDownloadActive,
|
||||
} from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
@@ -46,10 +46,14 @@ export abstract class BaseDownloader implements IDownloader {
|
||||
*/
|
||||
protected async downloadThumbnail(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
savePath: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
logger.info("Downloading thumbnail from:", thumbnailUrl);
|
||||
if (axiosConfig.proxy) {
|
||||
logger.debug("Using proxy for thumbnail download");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
fs.ensureDirSync(path.dirname(savePath));
|
||||
@@ -58,6 +62,7 @@ export abstract class BaseDownloader implements IDownloader {
|
||||
method: "GET",
|
||||
url: thumbnailUrl,
|
||||
responseType: "stream",
|
||||
...axiosConfig,
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(savePath);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SUBTITLES_DIR } from "../../config/paths";
|
||||
import { extractBilibiliVideoId } from "../../utils/helpers";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
@@ -22,7 +23,7 @@ export type {
|
||||
BilibiliVideoInfo,
|
||||
BilibiliVideosResult,
|
||||
CollectionDownloadResult,
|
||||
DownloadResult,
|
||||
DownloadResult
|
||||
};
|
||||
|
||||
export class BilibiliDownloader extends BaseDownloader {
|
||||
@@ -183,6 +184,11 @@ export class BilibiliDownloader extends BaseDownloader {
|
||||
videoUrl: string,
|
||||
baseFilename: string
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
return bilibiliSubtitle.downloadSubtitles(videoUrl, baseFilename);
|
||||
return bilibiliSubtitle.downloadSubtitles(
|
||||
videoUrl,
|
||||
baseFilename,
|
||||
SUBTITLES_DIR,
|
||||
"/subtitles"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import { cleanupTemporaryFiles, safeRemove } from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { ProgressTracker } from "../../utils/progressTracker";
|
||||
import {
|
||||
flagsToArgs,
|
||||
getAxiosProxyConfig,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} from "../../utils/ytDlpUtils";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
@@ -150,41 +156,26 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
thumbnail: thumbnailUrl,
|
||||
});
|
||||
|
||||
// 3. Select the best m3u8 URL from collected URLs
|
||||
// Prefer specific quality playlists over master playlists
|
||||
let m3u8Url: string | null = null;
|
||||
if (m3u8Urls.length > 0) {
|
||||
// Sort URLs: prefer specific quality playlists, avoid master playlists
|
||||
const sortedUrls = m3u8Urls.sort((a, b) => {
|
||||
const aIsMaster =
|
||||
a.includes("/playlist.m3u8") || a.includes("/master/");
|
||||
const bIsMaster =
|
||||
b.includes("/playlist.m3u8") || b.includes("/master/");
|
||||
// 3. Get user's yt-dlp configuration early to check for format sort
|
||||
// This helps determine m3u8 URL selection strategy and will be reused later
|
||||
const userConfig = getUserYtDlpConfig(url);
|
||||
const hasFormatSort = !!(userConfig.S || userConfig.formatSort);
|
||||
|
||||
// Prefer non-master playlists
|
||||
if (aIsMaster && !bIsMaster) return 1;
|
||||
if (!aIsMaster && bIsMaster) return -1;
|
||||
|
||||
// Among non-master playlists, prefer higher quality (480p > 240p)
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
});
|
||||
|
||||
m3u8Url = sortedUrls[0];
|
||||
// 4. Select the best m3u8 URL from collected URLs
|
||||
let m3u8Url = MissAVDownloader.selectBestM3u8Url(m3u8Urls, hasFormatSort);
|
||||
|
||||
if (m3u8Url) {
|
||||
logger.info(
|
||||
`Selected m3u8 URL from ${m3u8Urls.length} candidates:`,
|
||||
`Selected m3u8 URL from ${m3u8Urls.length} candidates (format sort: ${hasFormatSort}):`,
|
||||
m3u8Url
|
||||
);
|
||||
if (sortedUrls.length > 1) {
|
||||
logger.info("Alternative URLs:", sortedUrls.slice(1));
|
||||
const alternatives = m3u8Urls.filter(u => u !== m3u8Url);
|
||||
if (alternatives.length > 0) {
|
||||
logger.info("Alternative URLs:", alternatives);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
// 5. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
if (!m3u8Url) {
|
||||
logger.info(
|
||||
"m3u8 URL not found via network, trying regex extraction..."
|
||||
@@ -229,19 +220,32 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update the safe base filename with the actual title
|
||||
// 5. Get network configuration from user config (already loaded above)
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Get merge output format from user config or default to mp4
|
||||
const mergeOutputFormat = userConfig.mergeOutputFormat || "mp4";
|
||||
|
||||
// 6. Update the safe base filename with the actual title
|
||||
// Use the correct extension based on merge output format
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newVideoFilename = `${newSafeBaseFilename}.${mergeOutputFormat}`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const thumbnailDir = moveThumbnailsToVideoFolder
|
||||
? VIDEOS_DIR
|
||||
: IMAGES_DIR;
|
||||
const newThumbnailPath = path.join(thumbnailDir, newThumbnailFilename);
|
||||
|
||||
// 6. Download the video using yt-dlp with the m3u8 URL
|
||||
// 7. Download the video using yt-dlp with the m3u8 URL
|
||||
logger.info("Downloading video from m3u8 URL using yt-dlp:", m3u8Url);
|
||||
logger.info("Downloading video to:", newVideoPath);
|
||||
logger.info("Download ID:", downloadId);
|
||||
@@ -257,19 +261,53 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// Get format sort option if user specified it
|
||||
const formatSortValue = userConfig.S || userConfig.formatSort;
|
||||
|
||||
// Default format - use bestvideo*+bestaudio/best to support highest resolution
|
||||
// This allows downloading 1080p or higher if available
|
||||
let downloadFormat = "bestvideo*+bestaudio/best";
|
||||
|
||||
// If user specified a format, use it
|
||||
if (userConfig.f || userConfig.format) {
|
||||
downloadFormat = userConfig.f || userConfig.format;
|
||||
logger.info("Using user-specified format for MissAV:", downloadFormat);
|
||||
} else if (formatSortValue) {
|
||||
// If user specified format sort but not format, use a more permissive format
|
||||
// that allows format sort to work properly with m3u8 streams
|
||||
// This ensures format sort (e.g., -S res:360) can properly filter resolutions
|
||||
downloadFormat = "bestvideo+bestaudio/best";
|
||||
logger.info(
|
||||
"Using permissive format with format sort for MissAV:",
|
||||
downloadFormat,
|
||||
"format sort:",
|
||||
formatSortValue
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare flags for yt-dlp to download m3u8 stream
|
||||
// Dynamically determine Referer based on the input URL domain
|
||||
const urlObj = new URL(url);
|
||||
const referer = `${urlObj.protocol}//${urlObj.host}/`;
|
||||
const urlObjForReferer = new URL(url);
|
||||
const referer = `${urlObjForReferer.protocol}//${urlObjForReferer.host}/`;
|
||||
logger.info("Using Referer:", referer);
|
||||
|
||||
// Prepare flags object - merge user config with required settings
|
||||
const flags: any = {
|
||||
...networkConfig, // Apply network settings (proxy, etc.)
|
||||
output: newVideoPath,
|
||||
format: "best",
|
||||
mergeOutputFormat: "mp4",
|
||||
format: downloadFormat,
|
||||
mergeOutputFormat: mergeOutputFormat,
|
||||
addHeader: [`Referer:${referer}`, `User-Agent:${userAgent}`],
|
||||
};
|
||||
|
||||
// Apply format sort if user specified it
|
||||
if (formatSortValue) {
|
||||
flags.formatSort = formatSortValue;
|
||||
logger.info("Using format sort for MissAV:", formatSortValue);
|
||||
}
|
||||
|
||||
logger.info("Final MissAV yt-dlp flags:", flags);
|
||||
|
||||
// Use ProgressTracker for centralized progress parsing
|
||||
const progressTracker = new ProgressTracker(downloadId);
|
||||
const parseProgress = (output: string, source: "stdout" | "stderr") => {
|
||||
@@ -286,20 +324,15 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
|
||||
logger.info("Starting yt-dlp process with spawn...");
|
||||
|
||||
// Convert flags object to array of args
|
||||
const args = [
|
||||
m3u8Url,
|
||||
"--output",
|
||||
newVideoPath,
|
||||
"--format",
|
||||
"best",
|
||||
"--merge-output-format",
|
||||
"mp4",
|
||||
"--add-header",
|
||||
`Referer:${referer}`,
|
||||
"--add-header",
|
||||
`User-Agent:${userAgent}`,
|
||||
];
|
||||
// Convert flags object to array of args using the utility function
|
||||
const args = [m3u8Url, ...flagsToArgs(flags)];
|
||||
|
||||
// Log the full command for debugging
|
||||
logger.info(
|
||||
"Executing yt-dlp command:",
|
||||
YT_DLP_PATH,
|
||||
args.join(" ")
|
||||
);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -357,17 +390,21 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 7. Download and save the thumbnail
|
||||
// 8. Download and save the thumbnail
|
||||
if (thumbnailUrl) {
|
||||
// Use base class method via temporary instance
|
||||
const axiosConfig = userConfig.proxy
|
||||
? getAxiosProxyConfig(userConfig.proxy)
|
||||
: {};
|
||||
const downloader = new MissAVDownloader();
|
||||
thumbnailSaved = await downloader.downloadThumbnail(
|
||||
thumbnailUrl,
|
||||
newThumbnailPath
|
||||
newThumbnailPath,
|
||||
axiosConfig
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Get video duration
|
||||
// 9. Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import(
|
||||
@@ -381,7 +418,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
logger.error("Failed to extract duration from MissAV video:", e);
|
||||
}
|
||||
|
||||
// 9. Get file size
|
||||
// 10. Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
@@ -392,7 +429,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
logger.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// 10. Save metadata
|
||||
// 11. Save metadata
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle,
|
||||
@@ -405,7 +442,9 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${newVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${newThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${newThumbnailFilename}`
|
||||
: `/images/${newThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
@@ -419,20 +458,133 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
return videoData;
|
||||
} catch (error: any) {
|
||||
logger.error("Error in downloadMissAVVideo:", error);
|
||||
// Cleanup
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoPath = path.join(VIDEOS_DIR, `${newSafeBaseFilename}.mp4`);
|
||||
const newThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${newSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(newVideoPath)) await safeRemove(newVideoPath);
|
||||
if (fs.existsSync(newThumbnailPath)) await safeRemove(newThumbnailPath);
|
||||
// Cleanup - try to get the correct extension from config, fallback to mp4
|
||||
try {
|
||||
const cleanupConfig = getUserYtDlpConfig(url);
|
||||
const cleanupFormat = cleanupConfig.mergeOutputFormat || "mp4";
|
||||
const cleanupSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const cleanupVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.${cleanupFormat}`
|
||||
);
|
||||
const cleanupThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${cleanupSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPath)) await safeRemove(cleanupVideoPath);
|
||||
if (fs.existsSync(cleanupThumbnailPath))
|
||||
await safeRemove(cleanupThumbnailPath);
|
||||
// Also try mp4 in case the file was created with default extension
|
||||
const cleanupVideoPathMp4 = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.mp4`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPathMp4))
|
||||
await safeRemove(cleanupVideoPathMp4);
|
||||
} catch (cleanupError) {
|
||||
// If cleanup fails, try with default mp4 extension
|
||||
const cleanupSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const cleanupVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.mp4`
|
||||
);
|
||||
const cleanupThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${cleanupSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPath)) await safeRemove(cleanupVideoPath);
|
||||
if (fs.existsSync(cleanupThumbnailPath))
|
||||
await safeRemove(cleanupThumbnailPath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to select best m3u8 URL
|
||||
static selectBestM3u8Url(urls: string[], hasFormatSort: boolean): string | null {
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
const sortedUrls = [...urls].sort((a, b) => {
|
||||
// 1. Priority: surrit.com
|
||||
const aIsSurrit = a.includes("surrit.com");
|
||||
const bIsSurrit = b.includes("surrit.com");
|
||||
if (aIsSurrit && !bIsSurrit) return -1;
|
||||
if (!aIsSurrit && bIsSurrit) return 1;
|
||||
|
||||
// 2. Priority: Master playlist (playlist.m3u8 specifically for surrit, or general master)
|
||||
// We generally prefer master playlists because they contain all variants, allowing yt-dlp to pick the best.
|
||||
// The previous logic penalized master playlists without explicit resolution, which caused issues.
|
||||
const aIsMaster = a.includes("/playlist.m3u8") || a.includes("/master/");
|
||||
const bIsMaster = b.includes("/playlist.m3u8") || b.includes("/master/");
|
||||
|
||||
// If we are strictly comparing surrit URLs (both are surrit), we prefer the master playlist
|
||||
// because it's the "cleanest" source.
|
||||
if (aIsSurrit && bIsSurrit) {
|
||||
const aIsPlaylistM3u8 = a.includes("playlist.m3u8");
|
||||
const bIsPlaylistM3u8 = b.includes("playlist.m3u8");
|
||||
if (aIsPlaylistM3u8 && !bIsPlaylistM3u8) return -1;
|
||||
if (!aIsPlaylistM3u8 && bIsPlaylistM3u8) return 1;
|
||||
}
|
||||
|
||||
// If format sort is enabled, we almost always want the master playlist
|
||||
if (hasFormatSort) {
|
||||
if (aIsMaster && !bIsMaster) return -1;
|
||||
if (!aIsMaster && bIsMaster) return 1;
|
||||
} else {
|
||||
// If NO format sort, previously we preferred specific resolution.
|
||||
// BUT, given the bug report where a 240p stream was picked over a master,
|
||||
// we should probably trust the master playlist more particularly if the alternative is low quality.
|
||||
// However, if we have a high quality specific stream (e.g. 720p/1080p explicit), that might be fine.
|
||||
|
||||
// Let's refine: If one is surrit master, pick it. (Handled by step 1 & surrit sub-logic)
|
||||
// If neither is surrit, and one is master...
|
||||
|
||||
// If both are master or both are not master, compare resolution.
|
||||
}
|
||||
|
||||
// 3. Priority: Resolution (detected from URL)
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
|
||||
// If we have a significant resolution difference, we might prefer the higher one
|
||||
// UNLESS one is a master playlist and the other is a low res specific one.
|
||||
// If one is master (0p detected) and other is 240p, 0p (master) should win if it's likely to contain better streams.
|
||||
|
||||
// Updated Strategy:
|
||||
// If both have resolution, compare them.
|
||||
if (aQualityNum > 0 && bQualityNum > 0) {
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
}
|
||||
|
||||
// If one is master (assumed 0p from URL) and other is specific resolution:
|
||||
// If we are prioritizing master playlists (e.g. because of surrit or format sort), master wins.
|
||||
// If we are NOT specifically prioritizing master, we still might want to prefer it over very low res (e.g. < 480p).
|
||||
if (aIsMaster && bQualityNum > 0 && bQualityNum < 480) return -1; // Master wins over < 480p
|
||||
if (bIsMaster && aQualityNum > 0 && aQualityNum < 480) return 1; // Master wins over < 480p
|
||||
|
||||
// Fallback: Default to higher number (so 720p wins over 0p/master if we didn't catch it above)
|
||||
// This preserves 'best attempt' for specific high quality URLs if they exist not on surrit.
|
||||
if (aQualityNum !== bQualityNum) {
|
||||
return bQualityNum - aQualityNum;
|
||||
}
|
||||
|
||||
// Final tie-breaker: prefer master if all else equal
|
||||
if (aIsMaster && !bIsMaster) return -1;
|
||||
if (!aIsMaster && bIsMaster) return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return sortedUrls[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ export async function cleanupTempDir(tempDir: string): Promise<void> {
|
||||
*/
|
||||
export function prepareFilePaths(
|
||||
mergeOutputFormat: string,
|
||||
collectionName?: string
|
||||
collectionName?: string,
|
||||
moveThumbnailsToVideoFolder: boolean = false
|
||||
): FilePaths {
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -61,9 +62,13 @@ export function prepareFilePaths(
|
||||
const videoDir = collectionName
|
||||
? path.join(VIDEOS_DIR, collectionName)
|
||||
: VIDEOS_DIR;
|
||||
const imageDir = collectionName
|
||||
? path.join(IMAGES_DIR, collectionName)
|
||||
: IMAGES_DIR;
|
||||
const imageDir = moveThumbnailsToVideoFolder
|
||||
? collectionName
|
||||
? path.join(VIDEOS_DIR, collectionName)
|
||||
: VIDEOS_DIR
|
||||
: collectionName
|
||||
? path.join(IMAGES_DIR, collectionName)
|
||||
: IMAGES_DIR;
|
||||
|
||||
// Ensure directories exist
|
||||
fs.ensureDirSync(videoDir);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from "axios";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { SUBTITLES_DIR } from "../../../config/paths";
|
||||
import { bccToVtt } from "../../../utils/bccToVtt";
|
||||
import { extractBilibiliVideoId } from "../../../utils/helpers";
|
||||
import { logger } from "../../../utils/logger";
|
||||
@@ -13,7 +12,9 @@ import { getCookieHeader } from "./bilibiliCookie";
|
||||
export async function downloadSubtitles(
|
||||
videoUrl: string,
|
||||
baseFilename: string,
|
||||
collectionName?: string
|
||||
subtitleDir: string,
|
||||
subtitlePathPrefix: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
try {
|
||||
const videoId = extractBilibiliVideoId(videoUrl);
|
||||
@@ -37,7 +38,7 @@ export async function downloadSubtitles(
|
||||
const viewApiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${videoId}`;
|
||||
let viewResponse;
|
||||
try {
|
||||
viewResponse = await axios.get(viewApiUrl, { headers });
|
||||
viewResponse = await axios.get(viewApiUrl, { headers, ...axiosConfig });
|
||||
} catch (viewError: any) {
|
||||
logger.error(`Failed to fetch view API: ${viewError.message}`);
|
||||
return [];
|
||||
@@ -55,7 +56,7 @@ export async function downloadSubtitles(
|
||||
logger.info(`Fetching subtitles from: ${playerApiUrl}`);
|
||||
let playerResponse;
|
||||
try {
|
||||
playerResponse = await axios.get(playerApiUrl, { headers });
|
||||
playerResponse = await axios.get(playerApiUrl, { headers, ...axiosConfig });
|
||||
} catch (playerError: any) {
|
||||
logger.warn(`Player API failed: ${playerError.message}`);
|
||||
// Continue to check view API fallback
|
||||
@@ -95,14 +96,6 @@ export async function downloadSubtitles(
|
||||
|
||||
const savedSubtitles = [];
|
||||
|
||||
// Determine subtitle directory based on collection name
|
||||
const subtitleDir = collectionName
|
||||
? path.join(SUBTITLES_DIR, collectionName)
|
||||
: SUBTITLES_DIR;
|
||||
const subtitlePathPrefix = collectionName
|
||||
? `/subtitles/${collectionName}`
|
||||
: `/subtitles`;
|
||||
|
||||
// Ensure subtitles directory exists
|
||||
fs.ensureDirSync(subtitleDir);
|
||||
|
||||
@@ -131,6 +124,7 @@ export async function downloadSubtitles(
|
||||
try {
|
||||
const subResponse = await axios.get(absoluteSubUrl, {
|
||||
headers: cdnHeaders,
|
||||
...axiosConfig,
|
||||
});
|
||||
const vttContent = bccToVtt(subResponse.data);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { SUBTITLES_DIR } from "../../../config/paths";
|
||||
import { DownloadCancelledError } from "../../../errors/DownloadErrors";
|
||||
import { formatBytes } from "../../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../../utils/helpers";
|
||||
@@ -8,6 +9,7 @@ import { ProgressTracker } from "../../../utils/progressTracker";
|
||||
import {
|
||||
executeYtDlpJson,
|
||||
executeYtDlpSpawn,
|
||||
getAxiosProxyConfig,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} from "../../../utils/ytDlpUtils";
|
||||
@@ -55,9 +57,10 @@ class BilibiliDownloaderHelper extends BaseDownloader {
|
||||
|
||||
public async downloadThumbnailPublic(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
savePath: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<boolean> {
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath);
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath, axiosConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,10 +258,14 @@ export async function downloadVideo(
|
||||
let thumbnailSaved = false;
|
||||
if (thumbnailUrl) {
|
||||
// Use base class method via temporary instance
|
||||
const axiosConfig = userConfig.proxy
|
||||
? getAxiosProxyConfig(userConfig.proxy)
|
||||
: {};
|
||||
const downloader = new BilibiliDownloaderHelper();
|
||||
thumbnailSaved = await downloader.downloadThumbnailPublic(
|
||||
thumbnailUrl,
|
||||
thumbnailPath
|
||||
thumbnailPath,
|
||||
axiosConfig
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,6 +315,11 @@ export async function downloadSinglePart(
|
||||
// Get user's yt-dlp configuration for merge output format
|
||||
const userConfig = getUserYtDlpConfig(url);
|
||||
const mergeOutputFormat = userConfig.mergeOutputFormat || "mp4";
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const moveSubtitlesToVideoFolder =
|
||||
settings.moveSubtitlesToVideoFolder || false;
|
||||
|
||||
// Create a safe base filename (without extension)
|
||||
const timestamp = Date.now();
|
||||
@@ -315,7 +327,8 @@ export async function downloadSinglePart(
|
||||
// Prepare file paths using the file manager
|
||||
const { videoPath, thumbnailPath, videoDir, imageDir } = prepareFilePaths(
|
||||
mergeOutputFormat,
|
||||
collectionName
|
||||
collectionName,
|
||||
moveThumbnailsToVideoFolder
|
||||
);
|
||||
|
||||
let videoTitle,
|
||||
@@ -420,10 +433,27 @@ export async function downloadSinglePart(
|
||||
}> = [];
|
||||
try {
|
||||
logger.info("Attempting to download subtitles...");
|
||||
const subtitleDir = moveSubtitlesToVideoFolder
|
||||
? videoDir
|
||||
: collectionName
|
||||
? path.join(SUBTITLES_DIR, collectionName)
|
||||
: SUBTITLES_DIR;
|
||||
const subtitlePathPrefix = moveSubtitlesToVideoFolder
|
||||
? collectionName
|
||||
? `/videos/${collectionName}`
|
||||
: `/videos`
|
||||
: collectionName
|
||||
? `/subtitles/${collectionName}`
|
||||
: `/subtitles`;
|
||||
const axiosConfig = userConfig.proxy
|
||||
? getAxiosProxyConfig(userConfig.proxy)
|
||||
: {};
|
||||
subtitles = await downloadSubtitles(
|
||||
url,
|
||||
newSafeBaseFilename,
|
||||
collectionName
|
||||
subtitleDir,
|
||||
subtitlePathPrefix,
|
||||
axiosConfig
|
||||
);
|
||||
logger.info(`Downloaded ${subtitles.length} subtitles`);
|
||||
} catch (e) {
|
||||
@@ -462,9 +492,13 @@ export async function downloadSinglePart(
|
||||
? `/videos/${collectionName}/${finalVideoFilename}`
|
||||
: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? collectionName
|
||||
? `/images/${collectionName}/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? collectionName
|
||||
? `/videos/${collectionName}/${finalThumbnailFilename}`
|
||||
: `/videos/${finalThumbnailFilename}`
|
||||
: collectionName
|
||||
? `/images/${collectionName}/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
duration: duration,
|
||||
fileSize: fileSize,
|
||||
|
||||
@@ -37,6 +37,7 @@ export function prepareDownloadFlags(
|
||||
}
|
||||
|
||||
// Prepare base flags from user config (excluding output options we manage)
|
||||
// Explicitly preserve network-related options like proxy
|
||||
const {
|
||||
output: _output, // Ignore user output template (we manage this)
|
||||
o: _o,
|
||||
@@ -50,9 +51,18 @@ export function prepareDownloadFlags(
|
||||
convertSubs: userConvertSubs,
|
||||
// Extract user merge output format (use it if provided)
|
||||
mergeOutputFormat: userMergeOutputFormat,
|
||||
proxy: _proxy, // Proxy is handled separately in networkOptions to ensure it's preserved
|
||||
...safeUserConfig
|
||||
} = config;
|
||||
|
||||
// Explicitly preserve proxy and other network options to ensure they're not lost
|
||||
// This is critical for download operations that need proxy settings
|
||||
const networkOptions: Record<string, any> = {};
|
||||
if (config.proxy) {
|
||||
networkOptions.proxy = config.proxy;
|
||||
logger.debug("Preserving proxy in networkOptions:", config.proxy);
|
||||
}
|
||||
|
||||
// Get format sort option if user specified it
|
||||
const formatSortValue = userFormatSort || userFormatSort2;
|
||||
|
||||
@@ -73,8 +83,10 @@ export function prepareDownloadFlags(
|
||||
const mergeOutputFormat = userMergeOutputFormat || defaultMergeFormat;
|
||||
|
||||
// Prepare flags - defaults first, then user config to allow overrides
|
||||
// Network options (like proxy) are applied last to ensure they're not overridden
|
||||
const flags: YtDlpFlags = {
|
||||
...safeUserConfig, // Apply user config
|
||||
...networkOptions, // Explicitly apply network options (proxy, etc.) to ensure they're preserved
|
||||
output: outputPath, // Always use our output path with correct extension
|
||||
format: defaultFormat,
|
||||
// Use user preferences if provided, otherwise use defaults
|
||||
@@ -99,7 +111,9 @@ export function prepareDownloadFlags(
|
||||
"bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best";
|
||||
}
|
||||
// Ensure merge output format is mp4 (already handled above, but log it)
|
||||
logger.info("Twitter/X URL detected - using MP4 format for Safari compatibility");
|
||||
logger.info(
|
||||
"Twitter/X URL detected - using MP4 format for Safari compatibility"
|
||||
);
|
||||
}
|
||||
|
||||
// Add YouTube specific flags if it's a YouTube URL
|
||||
@@ -153,6 +167,16 @@ export function prepareDownloadFlags(
|
||||
delete flags.extractorArgs;
|
||||
}
|
||||
|
||||
// Log proxy in final flags for debugging
|
||||
if (flags.proxy) {
|
||||
logger.debug("Proxy in final flags:", flags.proxy);
|
||||
} else if (config.proxy) {
|
||||
logger.warn(
|
||||
"Proxy was in config but not in final flags. Config proxy:",
|
||||
config.proxy
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Final yt-dlp flags:", flags);
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,24 +32,51 @@ class YtDlpDownloaderHelper extends BaseDownloader {
|
||||
*/
|
||||
export async function processSubtitles(
|
||||
baseFilename: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
moveSubtitlesToVideoFolder: boolean = false
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
const subtitles: Array<{ language: string; filename: string; path: string }> =
|
||||
[];
|
||||
|
||||
logger.info(
|
||||
`Processing subtitles for ${baseFilename}, move to video folder: ${moveSubtitlesToVideoFolder}`
|
||||
);
|
||||
|
||||
const downloader = new YtDlpDownloaderHelper();
|
||||
|
||||
try {
|
||||
const subtitleFiles = fs
|
||||
.readdirSync(VIDEOS_DIR)
|
||||
.filter(
|
||||
(file: string) =>
|
||||
file.startsWith(baseFilename) && file.endsWith(".vtt")
|
||||
);
|
||||
const subtitleExtensions = new Set([
|
||||
".vtt",
|
||||
".srt",
|
||||
".ass",
|
||||
".ssa",
|
||||
".sub",
|
||||
".ttml",
|
||||
".dfxp",
|
||||
".sbv",
|
||||
]);
|
||||
const searchDirs = [VIDEOS_DIR, SUBTITLES_DIR];
|
||||
const subtitleFiles: Array<{ dir: string; file: string }> = [];
|
||||
const seenFiles = new Set<string>();
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
const files = fs.readdirSync(dir).filter((file: string) => {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return file.startsWith(baseFilename) && subtitleExtensions.has(ext);
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
if (seenFiles.has(file)) {
|
||||
continue;
|
||||
}
|
||||
seenFiles.add(file);
|
||||
subtitleFiles.push({ dir, file });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Found ${subtitleFiles.length} subtitle files`);
|
||||
|
||||
for (const subtitleFile of subtitleFiles) {
|
||||
for (const { dir, file: subtitleFile } of subtitleFiles) {
|
||||
// Check if download was cancelled during subtitle processing
|
||||
try {
|
||||
downloader.throwIfCancelledPublic(downloadId);
|
||||
@@ -60,27 +87,47 @@ export async function processSubtitles(
|
||||
|
||||
// Parse language from filename (e.g., video_123.en.vtt -> en)
|
||||
const match = subtitleFile.match(
|
||||
/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.vtt$/
|
||||
/\.([a-z]{2}(?:-[A-Z]{2})?)(?:\..*?)?\.[^.]+$/
|
||||
);
|
||||
const language = match ? match[1] : "unknown";
|
||||
const extension = path.extname(subtitleFile);
|
||||
|
||||
// Move subtitle to subtitles directory
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
const destSubFilename = `${baseFilename}.${language}.vtt`;
|
||||
const destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
|
||||
// Move subtitle to subtitles directory or keep in video directory if requested
|
||||
const sourceSubPath = path.join(dir, subtitleFile);
|
||||
const destSubFilename = `${baseFilename}.${language}${extension}`;
|
||||
let destSubPath: string;
|
||||
let webPath: string;
|
||||
|
||||
// Read VTT file and fix alignment for centering
|
||||
let vttContent = fs.readFileSync(sourceSubPath, "utf-8");
|
||||
// Replace align:start with align:middle for centered subtitles
|
||||
// Also remove position:0% which forces left positioning
|
||||
vttContent = vttContent.replace(/ align:start/g, " align:middle");
|
||||
vttContent = vttContent.replace(/ position:0%/g, "");
|
||||
if (moveSubtitlesToVideoFolder) {
|
||||
destSubPath = path.join(VIDEOS_DIR, destSubFilename);
|
||||
webPath = `/videos/${destSubFilename}`;
|
||||
} else {
|
||||
destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
|
||||
webPath = `/subtitles/${destSubFilename}`;
|
||||
}
|
||||
|
||||
// Write cleaned VTT to destination
|
||||
fs.writeFileSync(destSubPath, vttContent, "utf-8");
|
||||
if (extension.toLowerCase() === ".vtt") {
|
||||
// Read VTT file and fix alignment for centering
|
||||
let vttContent = fs.readFileSync(sourceSubPath, "utf-8");
|
||||
// Replace align:start with align:middle for centered subtitles
|
||||
// Also remove position:0% which forces left positioning
|
||||
vttContent = vttContent.replace(/ align:start/g, " align:middle");
|
||||
vttContent = vttContent.replace(/ position:0%/g, "");
|
||||
|
||||
// Remove original file
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
// Write cleaned VTT to destination
|
||||
fs.writeFileSync(destSubPath, vttContent, "utf-8");
|
||||
} else if (sourceSubPath !== destSubPath) {
|
||||
fs.copyFileSync(sourceSubPath, destSubPath);
|
||||
}
|
||||
|
||||
// Remove original file if we moved it (if dest is different from source)
|
||||
// If moveSubtitlesToVideoFolder is true, destSubPath might be same as sourceSubPath
|
||||
// but with different name (e.g. video_uuid.en.vtt vs video_uuid.vtt)
|
||||
// Actually source is usually video_uuid.en.vtt (from yt-dlp) and dest is video_uuid.en.vtt
|
||||
// So if names are same and dir is same, we're just overwriting in place, which is fine
|
||||
if (sourceSubPath !== destSubPath) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Processed and moved subtitle ${subtitleFile} to ${destSubPath}`
|
||||
@@ -89,7 +136,7 @@ export async function processSubtitles(
|
||||
subtitles.push({
|
||||
language,
|
||||
filename: destSubFilename,
|
||||
path: `/subtitles/${destSubFilename}`,
|
||||
path: webPath,
|
||||
});
|
||||
}
|
||||
} catch (subtitleError) {
|
||||
@@ -100,4 +147,3 @@ export async function processSubtitles(
|
||||
|
||||
return subtitles;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { ProgressTracker } from "../../../utils/progressTracker";
|
||||
import {
|
||||
executeYtDlpJson,
|
||||
executeYtDlpSpawn,
|
||||
getAxiosProxyConfig,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} from "../../../utils/ytDlpUtils";
|
||||
import * as storageService from "../../storageService";
|
||||
@@ -44,9 +46,10 @@ class YtDlpDownloaderHelper extends BaseDownloader {
|
||||
|
||||
public async downloadThumbnailPublic(
|
||||
thumbnailUrl: string,
|
||||
savePath: string
|
||||
savePath: string,
|
||||
axiosConfig: any = {}
|
||||
): Promise<boolean> {
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath);
|
||||
return this.downloadThumbnail(thumbnailUrl, savePath, axiosConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +89,13 @@ export async function downloadVideo(
|
||||
try {
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Get user's yt-dlp configuration for network options (including proxy)
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Get video info first
|
||||
const info = await executeYtDlpJson(videoUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
preferFreeFormats: true,
|
||||
...(PROVIDER_SCRIPT
|
||||
@@ -130,8 +138,23 @@ export async function downloadVideo(
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
|
||||
// Update paths
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const moveSubtitlesToVideoFolder =
|
||||
settings.moveSubtitlesToVideoFolder || false;
|
||||
|
||||
logger.info("File location settings:", {
|
||||
moveThumbnailsToVideoFolder,
|
||||
moveSubtitlesToVideoFolder,
|
||||
videoDir: VIDEOS_DIR,
|
||||
imageDir: IMAGES_DIR
|
||||
});
|
||||
|
||||
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
const newThumbnailPath = moveThumbnailsToVideoFolder
|
||||
? path.join(VIDEOS_DIR, finalThumbnailFilename)
|
||||
: path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
|
||||
logger.info("Preparing video download path:", newVideoPath);
|
||||
|
||||
@@ -142,16 +165,32 @@ export async function downloadVideo(
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's yt-dlp configuration
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
// Get user's yt-dlp configuration (reuse from above if available, otherwise fetch again)
|
||||
// Note: userConfig was already fetched above, but we need to ensure it's still valid
|
||||
const downloadUserConfig = userConfig || getUserYtDlpConfig(videoUrl);
|
||||
|
||||
// Log proxy configuration for debugging
|
||||
if (downloadUserConfig.proxy) {
|
||||
logger.info("Using proxy for download:", downloadUserConfig.proxy);
|
||||
}
|
||||
|
||||
// Prepare download flags
|
||||
const { flags, mergeOutputFormat } = prepareDownloadFlags(
|
||||
videoUrl,
|
||||
newVideoPath,
|
||||
userConfig
|
||||
downloadUserConfig
|
||||
);
|
||||
|
||||
// Log final flags to verify proxy is included
|
||||
if (flags.proxy) {
|
||||
logger.info("Proxy included in download flags:", flags.proxy);
|
||||
} else {
|
||||
logger.warn(
|
||||
"Proxy not found in download flags. User config proxy:",
|
||||
downloadUserConfig.proxy
|
||||
);
|
||||
}
|
||||
|
||||
// Update the video path to use the correct extension based on merge format
|
||||
const videoExtension = mergeOutputFormat;
|
||||
const newVideoPathWithFormat = newVideoPath.replace(
|
||||
@@ -181,7 +220,13 @@ export async function downloadVideo(
|
||||
// Clean up partial files
|
||||
logger.info("Cleaning up partial files...");
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename);
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename, IMAGES_DIR);
|
||||
|
||||
// Use fresh cleanup based on settings
|
||||
const currentSettings = storageService.getSettings();
|
||||
if (!currentSettings.moveThumbnailsToVideoFolder) {
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename, IMAGES_DIR);
|
||||
}
|
||||
|
||||
if (fs.existsSync(newThumbnailPath)) {
|
||||
await fs.remove(newThumbnailPath);
|
||||
}
|
||||
@@ -256,9 +301,17 @@ export async function downloadVideo(
|
||||
thumbnailSaved = false;
|
||||
|
||||
if (thumbnailUrl) {
|
||||
// Prepare axios config with proxy if available
|
||||
let axiosConfig = {};
|
||||
|
||||
if (downloadUserConfig.proxy) {
|
||||
axiosConfig = getAxiosProxyConfig(downloadUserConfig.proxy);
|
||||
}
|
||||
|
||||
thumbnailSaved = await downloader.downloadThumbnailPublic(
|
||||
thumbnailUrl,
|
||||
newThumbnailPath
|
||||
newThumbnailPath,
|
||||
axiosConfig
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,13 +324,21 @@ export async function downloadVideo(
|
||||
}
|
||||
|
||||
// Process subtitle files
|
||||
subtitles = await processSubtitles(newSafeBaseFilename, downloadId);
|
||||
subtitles = await processSubtitles(
|
||||
newSafeBaseFilename,
|
||||
downloadId,
|
||||
moveSubtitlesToVideoFolder
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error in download process:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
@@ -290,7 +351,11 @@ export async function downloadVideo(
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
subtitles: subtitles.length > 0 ? subtitles : undefined,
|
||||
duration: undefined, // Will be populated below
|
||||
channelUrl: channelUrl || undefined,
|
||||
@@ -345,7 +410,9 @@ export async function downloadVideo(
|
||||
? finalThumbnailFilename
|
||||
: existingVideo.thumbnailFilename,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: existingVideo.thumbnailPath,
|
||||
duration: videoData.duration,
|
||||
fileSize: videoData.fileSize,
|
||||
|
||||
343
backend/src/services/passkeyService.ts
Normal file
343
backend/src/services/passkeyService.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type {
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
} from "@simplewebauthn/server";
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { generateToken } from "./authService";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
// RP (Relying Party) configuration
|
||||
const rpName = "MyTube";
|
||||
const rpID = process.env.RP_ID || "localhost"; // Default to localhost for development
|
||||
export const defaultOrigin = process.env.ORIGIN || `http://${rpID}:5550`; // Frontend origin
|
||||
const origin = defaultOrigin;
|
||||
|
||||
// Storage key for passkeys
|
||||
const PASSKEYS_STORAGE_KEY = "passkeys";
|
||||
|
||||
interface StoredPasskey {
|
||||
credentialID: string; // Base64url encoded
|
||||
credentialPublicKey: string; // Base64 encoded
|
||||
counter: number;
|
||||
transports?: string[];
|
||||
id: string; // Same as credentialID for convenience
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
rpID?: string; // Store the RP_ID used during registration for debugging
|
||||
origin?: string; // Store the origin used during registration for debugging
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored passkeys
|
||||
*/
|
||||
export function getPasskeys(): StoredPasskey[] {
|
||||
try {
|
||||
const settings = storageService.getSettings();
|
||||
const passkeys = settings[PASSKEYS_STORAGE_KEY];
|
||||
if (!passkeys || !Array.isArray(passkeys)) {
|
||||
return [];
|
||||
}
|
||||
return passkeys;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error getting passkeys",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save passkeys to storage
|
||||
*/
|
||||
function savePasskeys(passkeys: StoredPasskey[]): void {
|
||||
try {
|
||||
storageService.saveSettings({
|
||||
[PASSKEYS_STORAGE_KEY]: passkeys,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error saving passkeys",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate registration options for creating a new passkey
|
||||
*/
|
||||
export async function generatePasskeyRegistrationOptions(
|
||||
userName: string = "MyTube User",
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{
|
||||
options: any;
|
||||
challenge: string;
|
||||
}> {
|
||||
const existingPasskeys = getPasskeys();
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName,
|
||||
rpID: effectiveRPID,
|
||||
userID: Buffer.from(userName),
|
||||
userName,
|
||||
timeout: 60000,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existingPasskeys.map((passkey) => ({
|
||||
id: Buffer.from(passkey.credentialID, "base64url").toString("base64url"),
|
||||
type: "public-key" as const,
|
||||
transports: passkey.transports as any,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: "platform",
|
||||
userVerification: "preferred",
|
||||
requireResidentKey: false,
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
|
||||
};
|
||||
|
||||
const options = await generateRegistrationOptions(opts);
|
||||
|
||||
// Store challenge temporarily (in a real app, you'd use a session or cache)
|
||||
// For simplicity, we'll return it and the frontend will send it back
|
||||
return {
|
||||
options,
|
||||
challenge: options.challenge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and store a new passkey
|
||||
*/
|
||||
export async function verifyPasskeyRegistration(
|
||||
body: any,
|
||||
challenge: string,
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{ verified: boolean; passkey?: StoredPasskey }> {
|
||||
try {
|
||||
const existingPasskeys = getPasskeys();
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
const effectiveOrigin = originOverride || origin;
|
||||
|
||||
logger.info(
|
||||
`Verifying passkey registration with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
|
||||
);
|
||||
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
response: body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: effectiveOrigin,
|
||||
expectedRPID: effectiveRPID,
|
||||
requireUserVerification: false,
|
||||
};
|
||||
|
||||
const verification = await verifyRegistrationResponse(opts);
|
||||
|
||||
if (verification.verified && verification.registrationInfo) {
|
||||
const { credential } = verification.registrationInfo;
|
||||
const credentialID = credential.id;
|
||||
const credentialPublicKey = Buffer.from(credential.publicKey).toString(
|
||||
"base64"
|
||||
);
|
||||
|
||||
const newPasskey: StoredPasskey = {
|
||||
credentialID,
|
||||
credentialPublicKey,
|
||||
counter: credential.counter || 0,
|
||||
transports: body.response.transports || credential.transports || [],
|
||||
id: credentialID,
|
||||
name: body.name || `Passkey ${existingPasskeys.length + 1}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
rpID: effectiveRPID, // Store RP_ID for debugging
|
||||
origin: effectiveOrigin, // Store origin for debugging
|
||||
};
|
||||
|
||||
logger.info(
|
||||
`Passkey registered successfully with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
|
||||
);
|
||||
|
||||
const updatedPasskeys = [...existingPasskeys, newPasskey];
|
||||
savePasskeys(updatedPasskeys);
|
||||
|
||||
logger.info("New passkey registered successfully");
|
||||
return { verified: true, passkey: newPasskey };
|
||||
}
|
||||
|
||||
return { verified: false };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error verifying passkey registration",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
return { verified: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authentication options for passkey login
|
||||
*/
|
||||
export async function generatePasskeyAuthenticationOptions(
|
||||
rpIDOverride?: string
|
||||
): Promise<{
|
||||
options: any;
|
||||
challenge: string;
|
||||
}> {
|
||||
const passkeys = getPasskeys();
|
||||
|
||||
if (passkeys.length === 0) {
|
||||
throw new Error("No passkeys registered");
|
||||
}
|
||||
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
|
||||
logger.info(
|
||||
`Generating authentication options with RP_ID: ${effectiveRPID}, Found ${passkeys.length} passkey(s)`
|
||||
);
|
||||
|
||||
// Log stored RP_IDs for debugging
|
||||
const storedRPIDs = passkeys.map((p) => p.rpID || "not set");
|
||||
logger.info(`Stored passkeys RP_IDs: ${storedRPIDs.join(", ")}`);
|
||||
|
||||
// Filter passkeys to only include those that match the current RP_ID
|
||||
// This is critical - browsers will only find passkeys that match the RP_ID
|
||||
const matchingPasskeys = passkeys.filter((passkey) => {
|
||||
// If passkey has stored RP_ID, it must match
|
||||
if (passkey.rpID) {
|
||||
return passkey.rpID === effectiveRPID;
|
||||
}
|
||||
// For passkeys without stored RP_ID (legacy data), include them as fallback
|
||||
// This allows old passkeys to still work
|
||||
return true;
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Using ${matchingPasskeys.length} passkey(s) matching RP_ID: ${effectiveRPID}`
|
||||
);
|
||||
|
||||
if (matchingPasskeys.length === 0) {
|
||||
throw new Error(
|
||||
`No passkeys found for RP_ID: ${effectiveRPID}. Please create a new passkey.`
|
||||
);
|
||||
}
|
||||
|
||||
// Since we only allow platform authenticators during registration (authenticatorAttachment: "platform"),
|
||||
// all passkeys should be platform authenticators. Explicitly set transports to ["internal"]
|
||||
// to ensure the browser uses the platform authenticator (fingerprint/face ID) instead of
|
||||
// falling back to cross-platform authentication (QR code)
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
timeout: 60000,
|
||||
allowCredentials: matchingPasskeys.map((passkey) => ({
|
||||
id: passkey.credentialID,
|
||||
type: "public-key" as const,
|
||||
// Always specify "internal" transport since we only register platform authenticators
|
||||
// This tells the browser to use the device's built-in authenticator (fingerprint/face ID)
|
||||
transports: ["internal"] as any,
|
||||
})),
|
||||
userVerification: "preferred",
|
||||
rpID: effectiveRPID,
|
||||
};
|
||||
|
||||
const options = await generateAuthenticationOptions(opts);
|
||||
|
||||
return {
|
||||
options,
|
||||
challenge: options.challenge,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Verify passkey authentication
|
||||
*/
|
||||
export async function verifyPasskeyAuthentication(
|
||||
body: any,
|
||||
challenge: string,
|
||||
originOverride?: string,
|
||||
rpIDOverride?: string
|
||||
): Promise<{ verified: boolean; token?: string; role?: "admin" | "visitor" }> {
|
||||
try {
|
||||
const passkeys = getPasskeys();
|
||||
// Find passkey by matching the credential ID
|
||||
// body.id is already in base64url format from the browser
|
||||
const passkey = passkeys.find((p) => p.credentialID === body.id);
|
||||
|
||||
if (!passkey) {
|
||||
logger.warn("Passkey not found for authentication");
|
||||
return { verified: false };
|
||||
}
|
||||
|
||||
const effectiveRPID = rpIDOverride || rpID;
|
||||
const effectiveOrigin = originOverride || origin;
|
||||
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
response: body,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: effectiveOrigin,
|
||||
expectedRPID: effectiveRPID,
|
||||
credential: {
|
||||
id: passkey.credentialID,
|
||||
publicKey: Buffer.from(passkey.credentialPublicKey, "base64") as any,
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports as any,
|
||||
},
|
||||
requireUserVerification: false,
|
||||
};
|
||||
|
||||
const verification = await verifyAuthenticationResponse(opts);
|
||||
|
||||
if (verification.verified) {
|
||||
// Update counter
|
||||
const updatedPasskeys = passkeys.map((p) =>
|
||||
p.credentialID === passkey.credentialID
|
||||
? { ...p, counter: verification.authenticationInfo.newCounter }
|
||||
: p
|
||||
);
|
||||
savePasskeys(updatedPasskeys);
|
||||
|
||||
logger.info("Passkey authentication successful");
|
||||
|
||||
// Generate admin token (Passkeys are currently only for admins)
|
||||
const token = generateToken({ role: "admin" });
|
||||
|
||||
return { verified: true, token, role: "admin" };
|
||||
}
|
||||
|
||||
return { verified: false };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error verifying passkey authentication",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
return { verified: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all passkeys
|
||||
*/
|
||||
export function removeAllPasskeys(): void {
|
||||
try {
|
||||
savePasskeys([]);
|
||||
logger.info("All passkeys removed");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error removing passkeys",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
import { defaultSettings } from "../types/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as loginAttemptService from "./loginAttemptService";
|
||||
import * as storageService from "./storageService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Settings, defaultSettings } from "../types/settings";
|
||||
|
||||
/**
|
||||
* Check if login is required (loginEnabled is true)
|
||||
*/
|
||||
export function isLoginRequired(): boolean {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
return mergedSettings.loginEnabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if password authentication is enabled
|
||||
@@ -11,12 +20,16 @@ import { Settings, defaultSettings } from "../types/settings";
|
||||
export function isPasswordEnabled(): {
|
||||
enabled: boolean;
|
||||
waitTime?: number;
|
||||
loginRequired?: boolean;
|
||||
} {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Return true only if login is enabled AND a password is set
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
// Return true only if login is enabled AND a password is set AND password login is allowed
|
||||
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password && passwordLoginAllowed;
|
||||
|
||||
// Check for remaining wait time
|
||||
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||
@@ -24,16 +37,22 @@ export function isPasswordEnabled(): {
|
||||
return {
|
||||
enabled: isEnabled,
|
||||
waitTime: remainingWaitTime > 0 ? remainingWaitTime : undefined,
|
||||
loginRequired: mergedSettings.loginEnabled === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify password for authentication
|
||||
* @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security
|
||||
*/
|
||||
import { generateToken } from "./authService";
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
role?: "admin" | "visitor";
|
||||
token?: string;
|
||||
waitTime?: number;
|
||||
failedAttempts?: number;
|
||||
message?: string;
|
||||
@@ -41,9 +60,19 @@ export async function verifyPassword(
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
if (!mergedSettings.password) {
|
||||
// If no password set but login enabled, allow access
|
||||
return { success: true };
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
// If password login is explicitly disabled, ONLY allow Admin to login via password (if they have one set)?
|
||||
// Or just block everyone? The frontend says "When disabled, password login is not available."
|
||||
// But typically Admin should always be able to login?
|
||||
// For now, let's respect the flag, but maybe we should allow it if we are matching the Admin password?
|
||||
// Let's stick to current logic: if blocked, blocked.
|
||||
if (!passwordLoginAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Password login is not allowed. Please use passkey authentication.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user can attempt login (wait time check)
|
||||
@@ -57,24 +86,187 @@ export async function verifyPassword(
|
||||
};
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
|
||||
if (isMatch) {
|
||||
// Reset failed attempts on successful login
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
return { success: true };
|
||||
// 1. Check Admin Password
|
||||
if (mergedSettings.password) {
|
||||
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
if (isAdminMatch) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "admin" });
|
||||
return { success: true, role: "admin", token };
|
||||
}
|
||||
} else {
|
||||
// Record failed attempt and get wait time
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
// If no admin password set, and login enabled, allow as admin
|
||||
if (mergedSettings.loginEnabled) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "admin" });
|
||||
return { success: true, role: "admin", token };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Visitor Password (if visitorPassword is set and visitor user is enabled)
|
||||
// Permission control is now based on user role
|
||||
// If password matches visitorPassword, assign visitor role
|
||||
const visitorUserEnabled = mergedSettings.visitorUserEnabled !== false;
|
||||
if (visitorUserEnabled && mergedSettings.visitorPassword) {
|
||||
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
|
||||
if (isVisitorMatch) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "visitor" });
|
||||
return { success: true, role: "visitor", token };
|
||||
}
|
||||
}
|
||||
|
||||
// No match
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect password",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify admin password for authentication
|
||||
* Only checks admin password, not visitor password
|
||||
*/
|
||||
export async function verifyAdminPassword(
|
||||
password: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
role?: "admin";
|
||||
token?: string;
|
||||
waitTime?: number;
|
||||
failedAttempts?: number;
|
||||
message?: string;
|
||||
}> {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
if (!passwordLoginAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect password",
|
||||
message: "Password login is not allowed. Please use passkey authentication.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user can attempt login (wait time check)
|
||||
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||
if (remainingWaitTime > 0) {
|
||||
return {
|
||||
success: false,
|
||||
waitTime: remainingWaitTime,
|
||||
message: "Too many failed attempts. Please wait before trying again.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check Admin Password only
|
||||
if (mergedSettings.password) {
|
||||
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
|
||||
if (isAdminMatch) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "admin" });
|
||||
return { success: true, role: "admin", token };
|
||||
}
|
||||
} else {
|
||||
// If no admin password set, and login enabled, allow as admin
|
||||
if (mergedSettings.loginEnabled) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "admin" });
|
||||
return { success: true, role: "admin", token };
|
||||
}
|
||||
}
|
||||
|
||||
// No match - record failed attempt
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect admin password",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify visitor password for authentication
|
||||
* Only checks visitor password, not admin password
|
||||
*/
|
||||
export async function verifyVisitorPassword(
|
||||
password: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
role?: "visitor";
|
||||
token?: string;
|
||||
waitTime?: number;
|
||||
failedAttempts?: number;
|
||||
message?: string;
|
||||
}> {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Check if visitor user is enabled (defaults to true for backward compatibility)
|
||||
const visitorUserEnabled = mergedSettings.visitorUserEnabled !== false;
|
||||
|
||||
if (!visitorUserEnabled) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Visitor user is not enabled.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
if (!passwordLoginAllowed) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Password login is not allowed. Please use passkey authentication.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user can attempt login (wait time check)
|
||||
const remainingWaitTime = loginAttemptService.canAttemptLogin();
|
||||
if (remainingWaitTime > 0) {
|
||||
return {
|
||||
success: false,
|
||||
waitTime: remainingWaitTime,
|
||||
message: "Too many failed attempts. Please wait before trying again.",
|
||||
};
|
||||
}
|
||||
|
||||
// Check Visitor Password only
|
||||
if (mergedSettings.visitorPassword) {
|
||||
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
|
||||
if (isVisitorMatch) {
|
||||
loginAttemptService.resetFailedAttempts();
|
||||
const token = generateToken({ role: "visitor" });
|
||||
return { success: true, role: "visitor", token };
|
||||
}
|
||||
} else {
|
||||
// No visitor password set
|
||||
return {
|
||||
success: false,
|
||||
message: "Visitor password is not configured.",
|
||||
};
|
||||
}
|
||||
|
||||
// No match - record failed attempt
|
||||
const waitTime = loginAttemptService.recordFailedAttempt();
|
||||
const failedAttempts = loginAttemptService.getFailedAttempts();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
waitTime,
|
||||
failedAttempts,
|
||||
message: "Incorrect visitor password",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,11 +277,57 @@ export async function hashPassword(password: string): Promise<string> {
|
||||
return await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
const RESET_PASSWORD_COOLDOWN = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
/**
|
||||
* Get the remaining cooldown time for password reset
|
||||
* Returns the remaining time in milliseconds, or 0 if no cooldown
|
||||
*/
|
||||
export function getResetPasswordCooldown(): number {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
const lastResetTime = (mergedSettings as any).lastPasswordResetTime as number | undefined;
|
||||
|
||||
if (!lastResetTime) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const timeSinceLastReset = Date.now() - lastResetTime;
|
||||
const remainingCooldown = RESET_PASSWORD_COOLDOWN - timeSinceLastReset;
|
||||
|
||||
return remainingCooldown > 0 ? remainingCooldown : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password to a random 8-character string
|
||||
* Returns the new password (should be logged, not sent to frontend)
|
||||
*/
|
||||
export async function resetPassword(): Promise<string> {
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
|
||||
// Check if password reset is allowed (defaults to true for backward compatibility)
|
||||
const allowResetPassword = mergedSettings.allowResetPassword !== false;
|
||||
|
||||
if (!allowResetPassword) {
|
||||
throw new Error("Password reset is not allowed. The allowResetPassword setting is disabled.");
|
||||
}
|
||||
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
|
||||
|
||||
if (!passwordLoginAllowed) {
|
||||
throw new Error("Password reset is not allowed when password login is disabled");
|
||||
}
|
||||
|
||||
// Check cooldown period (1 hour)
|
||||
const remainingCooldown = getResetPasswordCooldown();
|
||||
if (remainingCooldown > 0) {
|
||||
const minutes = Math.ceil(remainingCooldown / (60 * 1000));
|
||||
throw new Error(`Password reset is on cooldown. Please wait ${minutes} minute${minutes !== 1 ? 's' : ''} before trying again.`);
|
||||
}
|
||||
|
||||
// Generate random 8-character password using cryptographically secure random
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
@@ -101,11 +339,10 @@ export async function resetPassword(): Promise<string> {
|
||||
// Hash the new password
|
||||
const hashedPassword = await hashPassword(newPassword);
|
||||
|
||||
// Update settings with new password
|
||||
const settings = storageService.getSettings();
|
||||
const mergedSettings = { ...defaultSettings, ...settings };
|
||||
// Update settings with new password and reset timestamp
|
||||
mergedSettings.password = hashedPassword;
|
||||
mergedSettings.loginEnabled = true; // Ensure login is enabled
|
||||
(mergedSettings as any).lastPasswordResetTime = Date.now();
|
||||
|
||||
storageService.saveSettings(mergedSettings);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as storageService from "./storageService";
|
||||
import { Settings, defaultSettings } from "../types/settings";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as storageService from "./storageService";
|
||||
|
||||
/**
|
||||
* Validate and normalize settings values
|
||||
@@ -25,54 +25,6 @@ export function validateSettings(newSettings: Partial<Settings>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if visitor mode restrictions should apply
|
||||
*/
|
||||
export function checkVisitorModeRestrictions(
|
||||
existingSettings: Settings,
|
||||
newSettings: Partial<Settings>
|
||||
): {
|
||||
allowed: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
// If visitor mode is not enabled, no restrictions
|
||||
if (existingSettings.visitorMode !== true) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// If visitorMode is being explicitly set to false, allow the update
|
||||
if (newSettings.visitorMode === false) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// If visitorMode is explicitly set to true (already enabled), allow but only update visitorMode
|
||||
if (newSettings.visitorMode === true) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Allow CloudFlare tunnel settings updates (read-only access mechanism, doesn't violate visitor mode)
|
||||
const isOnlyCloudflareUpdate =
|
||||
(newSettings.cloudflaredTunnelEnabled !== undefined ||
|
||||
newSettings.cloudflaredToken !== undefined) &&
|
||||
Object.keys(newSettings).every(
|
||||
(key) =>
|
||||
key === "cloudflaredTunnelEnabled" ||
|
||||
key === "cloudflaredToken" ||
|
||||
key === "visitorMode"
|
||||
);
|
||||
|
||||
if (isOnlyCloudflareUpdate) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Block all other changes
|
||||
return {
|
||||
allowed: false,
|
||||
error:
|
||||
"Visitor mode is enabled. Only disabling visitor mode or updating CloudFlare settings is allowed.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tag deletions and update videos accordingly
|
||||
*/
|
||||
@@ -137,14 +89,31 @@ export async function prepareSettingsForSave(
|
||||
const prepared = { ...newSettings };
|
||||
|
||||
// Handle password hashing
|
||||
// Check if password login is allowed (defaults to true for backward compatibility)
|
||||
const passwordLoginAllowed = existingSettings.passwordLoginAllowed !== false;
|
||||
|
||||
if (prepared.password) {
|
||||
// If password is provided, hash it
|
||||
prepared.password = await hashPassword(prepared.password);
|
||||
// If password login is not allowed, reject password updates
|
||||
if (!passwordLoginAllowed) {
|
||||
// Remove password from prepared settings to prevent update
|
||||
delete prepared.password;
|
||||
logger.warn("Password update rejected: password login is not allowed");
|
||||
} else {
|
||||
// If password is provided and allowed, hash it
|
||||
prepared.password = await hashPassword(prepared.password);
|
||||
}
|
||||
} else {
|
||||
// If password is empty/not provided, keep existing password
|
||||
prepared.password = existingSettings.password;
|
||||
}
|
||||
|
||||
// Handle visitor password hashing
|
||||
if (prepared.visitorPassword) {
|
||||
prepared.visitorPassword = await hashPassword(prepared.visitorPassword);
|
||||
} else {
|
||||
prepared.visitorPassword = existingSettings.visitorPassword;
|
||||
}
|
||||
|
||||
// Handle tags
|
||||
const oldTags: string[] = existingSettings.tags || [];
|
||||
if (prepared.tags === undefined) {
|
||||
|
||||
@@ -10,29 +10,29 @@ export { initializeStorage } from "./initialization";
|
||||
// Download Status
|
||||
export {
|
||||
addActiveDownload,
|
||||
updateActiveDownload,
|
||||
getDownloadStatus,
|
||||
removeActiveDownload,
|
||||
setQueuedDownloads,
|
||||
getDownloadStatus,
|
||||
updateActiveDownload,
|
||||
} from "./downloadStatus";
|
||||
|
||||
// Download History
|
||||
export {
|
||||
addDownloadHistoryItem,
|
||||
clearDownloadHistory,
|
||||
getDownloadHistory,
|
||||
removeDownloadHistoryItem,
|
||||
clearDownloadHistory,
|
||||
} from "./downloadHistory";
|
||||
|
||||
// Video Download Tracking
|
||||
export {
|
||||
checkVideoDownloadBySourceId,
|
||||
checkVideoDownloadByUrl,
|
||||
recordVideoDownload,
|
||||
handleVideoDownloadCheck,
|
||||
markVideoDownloadDeleted,
|
||||
recordVideoDownload,
|
||||
updateVideoDownloadRecord,
|
||||
verifyVideoExists,
|
||||
handleVideoDownloadCheck,
|
||||
} from "./videoDownloadTracking";
|
||||
|
||||
// Settings
|
||||
@@ -40,31 +40,30 @@ export { getSettings, saveSettings } from "./settings";
|
||||
|
||||
// Videos
|
||||
export {
|
||||
getVideos,
|
||||
getVideoBySourceUrl,
|
||||
getVideoById,
|
||||
deleteVideo,
|
||||
formatLegacyFilenames,
|
||||
getVideoById,
|
||||
getVideoBySourceUrl,
|
||||
getVideos,
|
||||
saveVideo,
|
||||
updateVideo,
|
||||
deleteVideo,
|
||||
} from "./videos";
|
||||
|
||||
// Collections
|
||||
export {
|
||||
getCollections,
|
||||
getCollectionById,
|
||||
getCollectionByVideoId,
|
||||
getCollectionByName,
|
||||
generateUniqueCollectionName,
|
||||
saveCollection,
|
||||
addVideoToCollection,
|
||||
atomicUpdateCollection,
|
||||
deleteCollection,
|
||||
addVideoToCollection,
|
||||
removeVideoFromCollection,
|
||||
deleteCollectionWithFiles,
|
||||
deleteCollectionAndVideos,
|
||||
deleteCollectionWithFiles,
|
||||
generateUniqueCollectionName,
|
||||
getCollectionById,
|
||||
getCollectionByName,
|
||||
getCollectionByVideoId,
|
||||
getCollections,
|
||||
removeVideoFromCollection,
|
||||
saveCollection,
|
||||
} from "./collections";
|
||||
|
||||
// File Helpers
|
||||
export { findVideoFile, findImageFile, moveFile } from "./fileHelpers";
|
||||
|
||||
export { findImageFile, findVideoFile, moveFile } from "./fileHelpers";
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
UPLOADS_DIR,
|
||||
VIDEOS_DIR,
|
||||
} from "../../config/paths";
|
||||
import { MigrationError } from "../../errors/DownloadErrors";
|
||||
import { db, sqlite } from "../../db";
|
||||
import { downloads, videos } from "../../db/schema";
|
||||
import { MigrationError } from "../../errors/DownloadErrors";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { findVideoFile } from "./fileHelpers";
|
||||
|
||||
@@ -36,7 +36,10 @@ export function initializeStorage(): void {
|
||||
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
|
||||
logger.info("Cleared active downloads on startup");
|
||||
} catch (error) {
|
||||
logger.error("Error resetting active downloads", error instanceof Error ? error : new Error(String(error)));
|
||||
logger.error(
|
||||
"Error resetting active downloads",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
fs.writeFileSync(
|
||||
STATUS_DATA_PATH,
|
||||
JSON.stringify({ activeDownloads: [], queuedDownloads: [] }, null, 2)
|
||||
@@ -49,7 +52,10 @@ export function initializeStorage(): void {
|
||||
db.delete(downloads).where(eq(downloads.status, "active")).run();
|
||||
logger.info("Cleared active downloads from database on startup");
|
||||
} catch (error) {
|
||||
logger.error("Error clearing active downloads from database", error instanceof Error ? error : new Error(String(error)));
|
||||
logger.error(
|
||||
"Error clearing active downloads from database",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
|
||||
// Check and migrate tags column if needed
|
||||
@@ -65,7 +71,10 @@ export function initializeStorage(): void {
|
||||
logger.info("Migration successful.");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error checking/migrating tags column", error instanceof Error ? error : new Error(String(error)));
|
||||
logger.error(
|
||||
"Error checking/migrating tags column",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
throw new MigrationError(
|
||||
"Failed to migrate tags column",
|
||||
"tags_column",
|
||||
@@ -198,7 +207,10 @@ export function initializeStorage(): void {
|
||||
.run();
|
||||
} catch (indexError) {
|
||||
// Indexes might already exist, ignore error
|
||||
logger.debug("Index creation skipped (may already exist)", indexError instanceof Error ? indexError : new Error(String(indexError)));
|
||||
logger.debug(
|
||||
"Index creation skipped (may already exist)",
|
||||
indexError instanceof Error ? indexError : new Error(String(indexError))
|
||||
);
|
||||
}
|
||||
|
||||
// Check download_history table for video_id, downloaded_at, deleted_at columns
|
||||
@@ -276,13 +288,16 @@ export function initializeStorage(): void {
|
||||
`
|
||||
)
|
||||
.run();
|
||||
if (result.changes > 0) {
|
||||
if (result && result.changes > 0) {
|
||||
logger.info(
|
||||
`Backfilled video_id for ${result.changes} download history items.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error backfilling video_id in download history", error instanceof Error ? error : new Error(String(error)));
|
||||
logger.error(
|
||||
"Error backfilling video_id in download history",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -17,7 +17,6 @@ describe('settings types', () => {
|
||||
websiteName: "MyTube",
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
visitorMode: false,
|
||||
infiniteScroll: false,
|
||||
videoColumns: 4,
|
||||
pauseOnFocusLoss: false,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface Settings {
|
||||
loginEnabled: boolean;
|
||||
password?: string;
|
||||
passwordLoginAllowed?: boolean;
|
||||
allowResetPassword?: boolean;
|
||||
defaultAutoPlay: boolean;
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
@@ -21,7 +23,8 @@ export interface Settings {
|
||||
proxyOnlyYoutube?: boolean;
|
||||
moveSubtitlesToVideoFolder?: boolean;
|
||||
moveThumbnailsToVideoFolder?: boolean;
|
||||
visitorMode?: boolean;
|
||||
visitorPassword?: string;
|
||||
visitorUserEnabled?: boolean;
|
||||
infiniteScroll?: boolean;
|
||||
videoColumns?: number;
|
||||
cloudflaredTunnelEnabled?: boolean;
|
||||
@@ -47,10 +50,7 @@ export const defaultSettings: Settings = {
|
||||
websiteName: "MyTube",
|
||||
itemsPerPage: 12,
|
||||
showYoutubeSearch: true,
|
||||
visitorMode: false,
|
||||
infiniteScroll: false,
|
||||
videoColumns: 4,
|
||||
pauseOnFocusLoss: false,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -58,8 +58,12 @@ export function validatePathWithinDirectory(
|
||||
|
||||
// Reconstruct paths from validated components only
|
||||
// This ensures no path traversal sequences can exist
|
||||
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
|
||||
const sanitizedFilePath = path.isAbsolute(filePath)
|
||||
? path.sep + path.join(...sanitizedFilePathParts)
|
||||
: path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
|
||||
? path.sep + path.join(...sanitizedAllowedDirParts)
|
||||
: path.join(...sanitizedAllowedDirParts);
|
||||
|
||||
// Final validation: ensure reconstructed paths don't contain traversal sequences
|
||||
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
|
||||
@@ -130,8 +134,12 @@ export function resolveSafePath(filePath: string, allowedDir: string): string {
|
||||
|
||||
// Reconstruct paths from validated components only
|
||||
// This ensures no path traversal sequences can exist
|
||||
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
|
||||
const sanitizedFilePath = path.isAbsolute(filePath)
|
||||
? path.sep + path.join(...sanitizedFilePathParts)
|
||||
: path.join(...sanitizedFilePathParts);
|
||||
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
|
||||
? path.sep + path.join(...sanitizedAllowedDirParts)
|
||||
: path.join(...sanitizedAllowedDirParts);
|
||||
|
||||
// Final validation: ensure reconstructed paths don't contain traversal sequences
|
||||
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
|
||||
|
||||
@@ -509,3 +509,38 @@ export function getNetworkConfigFromUserConfig(
|
||||
|
||||
return networkOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to convert a proxy URL string into an Axios config object
|
||||
* Supports http/https proxies with authentication
|
||||
* Format: http://user:pass@host:port
|
||||
*/
|
||||
export function getAxiosProxyConfig(proxyUrl: string): any {
|
||||
if (!proxyUrl) return {};
|
||||
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
|
||||
const isHttps = url.protocol === "https:";
|
||||
const defaultPort = isHttps ? 443 : 80;
|
||||
|
||||
// Axios proxy config structure
|
||||
const proxyConfig: any = {
|
||||
protocol: url.protocol.replace(":", ""),
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10) || defaultPort,
|
||||
};
|
||||
|
||||
if (url.username || url.password) {
|
||||
proxyConfig.auth = {
|
||||
username: url.username,
|
||||
password: url.password,
|
||||
};
|
||||
}
|
||||
|
||||
return { proxy: proxyConfig };
|
||||
} catch (error) {
|
||||
console.error("Invalid proxy URL:", proxyUrl);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.32",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.32",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.13.2",
|
||||
@@ -1918,6 +1919,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.32",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,6 +16,7 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@@ -11,7 +11,6 @@ import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { SnackbarProvider } from './contexts/SnackbarContext';
|
||||
import { ThemeContextProvider } from './contexts/ThemeContext';
|
||||
import { VideoProvider, useVideo } from './contexts/VideoContext';
|
||||
import { VisitorModeProvider } from './contexts/VisitorModeContext';
|
||||
import AuthorVideos from './pages/AuthorVideos';
|
||||
import CollectionPage from './pages/CollectionPage';
|
||||
import DownloadPage from './pages/DownloadPage';
|
||||
@@ -147,17 +146,15 @@ function App() {
|
||||
<ThemeContextProvider>
|
||||
<LanguageProvider>
|
||||
<SnackbarProvider>
|
||||
<VisitorModeProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
</VisitorModeProvider>
|
||||
<AuthProvider>
|
||||
<VideoProvider>
|
||||
<CollectionProvider>
|
||||
<DownloadProvider>
|
||||
<AppContent />
|
||||
</DownloadProvider>
|
||||
</CollectionProvider>
|
||||
</VideoProvider>
|
||||
</AuthProvider>
|
||||
</SnackbarProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeContextProvider>
|
||||
|
||||
@@ -41,12 +41,11 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[8],
|
||||
},
|
||||
border: `1px solid ${theme.palette.secondary.main}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardActionArea onClick={handleClick} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
|
||||
@@ -76,7 +75,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
|
||||
|
||||
<Chip
|
||||
icon={<Folder />}
|
||||
label={`${collection.videos.length} videos`}
|
||||
label={collection.videos.length}
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{ position: 'absolute', bottom: 8, right: 8 }}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Brightness4, Brightness7, Download, Settings } from '@mui/icons-materia
|
||||
import { Badge, Box, IconButton, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useThemeContext } from '../../contexts/ThemeContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import DownloadsMenu from './DownloadsMenu';
|
||||
import ManageMenu from './ManageMenu';
|
||||
import { DownloadInfo } from './types';
|
||||
@@ -32,14 +32,15 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
}) => {
|
||||
const { mode: currentThemeMode, toggleTheme } = useThemeContext();
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<>
|
||||
<IconButton color="inherit" onClick={onDownloadsClick}>
|
||||
<Badge badgeContent={activeDownloads.length + queuedDownloads.length} color="secondary">
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Help, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Help, Logout, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import {
|
||||
alpha,
|
||||
Divider,
|
||||
Fade,
|
||||
Menu,
|
||||
MenuItem,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface ManageMenuProps {
|
||||
@@ -21,8 +25,33 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const { logout } = useAuth();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Check if login is enabled
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const loginEnabled = settingsData?.loginEnabled || false;
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -68,6 +97,12 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
|
||||
<MenuItem onClick={() => { onClose(); navigate('/instruction'); }}>
|
||||
<Help sx={{ mr: 2 }} /> {t('instruction')}
|
||||
</MenuItem>
|
||||
{loginEnabled && <Divider />}
|
||||
{loginEnabled && (
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 2 }} /> {t('logout')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Box, Button, Collapse, Stack } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Logout, Settings, VideoLibrary } from '@mui/icons-material';
|
||||
import { Box, Button, Collapse, Divider, Stack } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { Collection, Video } from '../../types';
|
||||
import AuthorsList from '../AuthorsList';
|
||||
import Collections from '../Collections';
|
||||
@@ -42,17 +44,43 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
videos = [],
|
||||
availableTags = [],
|
||||
selectedTags = [],
|
||||
onTagToggle
|
||||
onTagToggle = () => { }
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { logout, userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const navigate = useNavigate();
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Check if login is enabled
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const loginEnabled = settingsData?.loginEnabled || false;
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse in={open} sx={{ width: '100%' }}>
|
||||
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
{/* Row 1: Search Input */}
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Box>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
@@ -91,6 +119,22 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Logout Button */}
|
||||
{loginEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={handleLogout}
|
||||
startIcon={<Logout />}
|
||||
>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile Navigation Items */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Collections
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { FormEvent } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
|
||||
interface SearchInputProps {
|
||||
videoUrl: string;
|
||||
@@ -36,7 +36,8 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onSubmit
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
@@ -58,10 +59,10 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder={visitorMode ? t('visitorModeReadOnly') || 'Visitor mode: Read-only' : t('enterUrlOrSearchTerm')}
|
||||
placeholder={isVisitor ? t('visitorModeReadOnly') || 'Visitor mode: Read-only' : t('enterUrlOrSearchTerm')}
|
||||
value={videoUrl}
|
||||
onChange={(e) => setVideoUrl(e.target.value)}
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
size="small"
|
||||
@@ -79,7 +80,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onClick={handlePaste}
|
||||
edge="start"
|
||||
size="small"
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
sx={{ ml: 0 }}
|
||||
>
|
||||
<ContentPaste />
|
||||
@@ -98,7 +99,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
onClick={handleClear}
|
||||
edge="end"
|
||||
size="small"
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
sx={{ mr: 0.5 }}
|
||||
>
|
||||
<Clear />
|
||||
@@ -107,7 +108,7 @@ const SearchInput: React.FC<SearchInputProps> = ({
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isSubmitting || visitorMode}
|
||||
disabled={isSubmitting || isVisitor}
|
||||
sx={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%', minWidth: 'auto', px: 3 }}
|
||||
>
|
||||
{isSubmitting ? <CircularProgress size={24} color="inherit" /> : <Search />}
|
||||
|
||||
@@ -9,8 +9,8 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
}));
|
||||
|
||||
const mockVisitorMode = false;
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: mockVisitorMode }),
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ userRole: mockVisitorMode ? 'visitor' : 'admin' }),
|
||||
}));
|
||||
|
||||
// Mock useMediaQuery
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useThemeContext } from '../../contexts/ThemeContext';
|
||||
import { useVideo } from '../../contexts/VideoContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ActionButtons from './ActionButtons';
|
||||
import Logo from './Logo';
|
||||
import MobileMenu from './MobileMenu';
|
||||
@@ -49,7 +49,8 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const { mode: themeMode } = useThemeContext();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const { availableTags, selectedTags, handleTagToggle } = useVideo();
|
||||
|
||||
const isSettingsPage = location.pathname.startsWith('/settings');
|
||||
@@ -61,7 +62,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
// Check for active subscriptions and tasks
|
||||
useEffect(() => {
|
||||
if (visitorMode) {
|
||||
if (isVisitor) {
|
||||
setHasActiveSubscriptions(false);
|
||||
return;
|
||||
}
|
||||
@@ -98,7 +99,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [visitorMode]);
|
||||
}, [isVisitor]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch settings to get website name and infinite scroll setting
|
||||
@@ -316,7 +317,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
{/* Desktop Layout */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', maxWidth: 800, mx: 'auto' }}>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
@@ -330,7 +331,7 @@ const Header: React.FC<HeaderProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: visitorMode ? 'auto' : 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ml: isVisitor ? 'auto' : 2 }}>
|
||||
<ActionButtons
|
||||
activeDownloads={activeDownloads}
|
||||
queuedDownloads={queuedDownloads}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Collection } from '../../types';
|
||||
|
||||
interface CollectionsTableProps {
|
||||
@@ -40,7 +40,8 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
getCollectionSize
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
|
||||
return (
|
||||
@@ -59,7 +60,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
<TableCell>{t('videos')}</TableCell>
|
||||
<TableCell>{t('size')}</TableCell>
|
||||
<TableCell>{t('created')}</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
{!isVisitor && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -71,7 +72,7 @@ const CollectionsTable: React.FC<CollectionsTableProps> = ({
|
||||
<TableCell>{collection.videos.length} videos</TableCell>
|
||||
<TableCell>{getCollectionSize(collection.videos)}</TableCell>
|
||||
<TableCell>{new Date(collection.createdAt).toLocaleDateString()}</TableCell>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteCollection')} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
|
||||
import { Video } from '../../types';
|
||||
import { formatDuration, formatSize } from '../../utils/formatUtils';
|
||||
@@ -96,7 +96,8 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
onUpdateVideo
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
|
||||
// Local editing state
|
||||
@@ -184,7 +185,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
{t('size')}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
{!visitorMode && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
{!isVisitor && <TableCell align="right">{t('actions')}</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -195,7 +196,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
<Link to={`/video/${video.id}`} style={{ display: 'block', width: '100%', height: '100%' }}>
|
||||
<ThumbnailImage video={video} />
|
||||
</Link>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Tooltip title={t('refreshThumbnail') || "Refresh Thumbnail"} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
size="small"
|
||||
@@ -255,7 +256,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditClick(video)}
|
||||
@@ -296,7 +297,7 @@ const VideosTable: React.FC<VideosTableProps> = ({
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{formatSize(video.fileSize)}</TableCell>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<TableCell align="right">
|
||||
<Tooltip title={t('deleteVideo')} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
|
||||
@@ -8,14 +8,10 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: false }),
|
||||
}));
|
||||
|
||||
// We need to support mocking the return value of useVisitorMode for specific tests
|
||||
const mockUseVisitorMode = vi.fn(() => ({ visitorMode: false }));
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => mockUseVisitorMode(),
|
||||
// We need to support mocking the return value of useAuth for specific tests
|
||||
const mockUseAuth = vi.fn(() => ({ userRole: 'admin' }));
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -59,11 +55,11 @@ describe('CollectionsTable', () => {
|
||||
});
|
||||
|
||||
it('should not show actions column in visitor mode', () => {
|
||||
mockUseVisitorMode.mockReturnValue({ visitorMode: true });
|
||||
mockUseAuth.mockReturnValue({ userRole: 'visitor' });
|
||||
render(<CollectionsTable {...defaultProps} />);
|
||||
expect(screen.queryByText('actions')).not.toBeInTheDocument();
|
||||
// Reset mock
|
||||
mockUseVisitorMode.mockReturnValue({ visitorMode: false });
|
||||
mockUseAuth.mockReturnValue({ userRole: 'admin' });
|
||||
});
|
||||
|
||||
it('should render pagination if totalPages > 1', () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: false }),
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ userRole: 'admin' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useCloudStorageUrl', () => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, FormControl, InputLabel, MenuItem, Select, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface BasicSettingsProps {
|
||||
@@ -10,6 +11,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 >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Alert, Box, CircularProgress, FormControlLabel, Switch, TextField, Tooltip, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../contexts/SnackbarContext';
|
||||
import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
|
||||
@@ -7,13 +8,14 @@ import { useCloudflareStatus } from '../../hooks/useCloudflareStatus';
|
||||
interface CloudflareSettingsProps {
|
||||
enabled?: boolean;
|
||||
token?: string;
|
||||
visitorMode?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token, visitorMode, onChange }) => {
|
||||
const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
@@ -63,7 +65,7 @@ const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token,
|
||||
<Switch
|
||||
checked={enabled ?? false}
|
||||
onChange={(e) => onChange('cloudflaredTunnelEnabled', e.target.checked)}
|
||||
disabled={visitorMode ?? false}
|
||||
disabled={isVisitor}
|
||||
/>
|
||||
}
|
||||
label={t('enableCloudflaredTunnel')}
|
||||
@@ -77,7 +79,7 @@ const CloudflareSettings: React.FC<CloudflareSettingsProps> = ({ enabled, token,
|
||||
value={token || ''}
|
||||
onChange={(e) => onChange('cloudflaredToken', e.target.value)}
|
||||
margin="normal"
|
||||
disabled={visitorMode ?? false}
|
||||
disabled={isVisitor}
|
||||
helperText={t('cloudflaredTokenHelper') || "Paste your tunnel token here, or leave empty to use a random Quick Tunnel."}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Settings } from '../../types';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
interface HookSettingsProps {
|
||||
settings: Settings;
|
||||
@@ -17,6 +18,11 @@ const API_URL = import.meta.env.VITE_API_URL;
|
||||
const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
const { t } = useLanguage();
|
||||
const [deleteHookName, setDeleteHookName] = useState<string | null>(null);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState<string | undefined>(undefined);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [pendingUpload, setPendingUpload] = useState<{ hookName: string; file: File } | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const { data: hookStatus, refetch: refetchHooks, isLoading } = useQuery({
|
||||
queryKey: ['hookStatus'],
|
||||
@@ -36,6 +42,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchHooks();
|
||||
setPendingUpload(null);
|
||||
setUploadError(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Upload failed:', error);
|
||||
const message = error.response?.data?.message || error.message;
|
||||
// Try to match risk command error
|
||||
// Backend sends: "Risk command detected: {command}. Upload rejected."
|
||||
const riskMatch = message?.match(/Risk command detected: (.*)\. Upload rejected\./);
|
||||
if (riskMatch && riskMatch[1]) {
|
||||
setUploadError(t('riskCommandDetected', { command: riskMatch[1] }));
|
||||
} else {
|
||||
setUploadError(message || t('uploadFailed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,10 +78,35 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadMutation.mutate({ hookName, file });
|
||||
|
||||
// Reset input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
|
||||
setPendingUpload({ hookName, file });
|
||||
setPasswordError(undefined);
|
||||
setUploadError(null);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifying(true);
|
||||
setPasswordError(undefined);
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
setShowPasswordModal(false);
|
||||
if (pendingUpload) {
|
||||
uploadMutation.mutate(pendingUpload);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response?.status === 429) {
|
||||
const waitTime = error.response.data.waitTime;
|
||||
setPasswordError(t('tooManyAttempts') + ` Try again in ${Math.ceil(waitTime / 1000)}s`);
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword'));
|
||||
}
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (hookName: string) => {
|
||||
@@ -109,6 +154,12 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
{t('taskHooksWarning')}
|
||||
</Alert>
|
||||
|
||||
{uploadError && (
|
||||
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setUploadError(null)}>
|
||||
{uploadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
@@ -185,6 +236,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
|
||||
cancelText={t('cancel') || 'Cancel'}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingUpload(null);
|
||||
setPasswordError(undefined);
|
||||
}}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('enterPassword')}
|
||||
message={t('enterPasswordToUploadHook') || 'Please enter your password to upload this hook script.'}
|
||||
error={passwordError}
|
||||
isLoading={isVerifying}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { Box, FormControlLabel, Switch, TextField } from '@mui/material';
|
||||
import React from 'react';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import FingerprintIcon from '@mui/icons-material/Fingerprint';
|
||||
import { Box, Button, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Settings } from '../../types';
|
||||
import { getWebAuthnErrorTranslationKey } from '../../utils/translations';
|
||||
import AlertModal from '../AlertModal';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface SecuritySettingsProps {
|
||||
settings: Settings;
|
||||
@@ -10,6 +20,133 @@ interface SecuritySettingsProps {
|
||||
|
||||
const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [alertTitle, setAlertTitle] = useState('');
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
|
||||
const showAlert = (title: string, message: string) => {
|
||||
setAlertTitle(title);
|
||||
setAlertMessage(message);
|
||||
setAlertOpen(true);
|
||||
};
|
||||
|
||||
// Check if passkeys exist
|
||||
const { data: passkeysData, refetch: refetchPasskeys } = useQuery({
|
||||
queryKey: ['passkeys-exists'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings/passkeys/exists`);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const passkeysExist = passkeysData?.exists || false;
|
||||
|
||||
// If passkeys don't exist, automatically enable and lock password login
|
||||
useEffect(() => {
|
||||
if (!passkeysExist && settings.loginEnabled && settings.passwordLoginAllowed === false) {
|
||||
onChange('passwordLoginAllowed', true);
|
||||
}
|
||||
}, [passkeysExist, settings.loginEnabled, settings.passwordLoginAllowed, onChange]);
|
||||
|
||||
// Create passkey mutation
|
||||
const createPasskeyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
|
||||
|
||||
// Step 1: Get registration options
|
||||
const optionsResponse = await axios.post(`${API_URL}/settings/passkeys/register`, {
|
||||
userName: 'MyTube User',
|
||||
});
|
||||
const { options, challenge } = optionsResponse.data;
|
||||
|
||||
// Step 2: Start registration with browser
|
||||
const attestationResponse = await startRegistration(options);
|
||||
|
||||
// Step 3: Verify registration
|
||||
const verifyResponse = await axios.post(`${API_URL}/settings/passkeys/register/verify`, {
|
||||
body: attestationResponse,
|
||||
challenge,
|
||||
});
|
||||
|
||||
if (!verifyResponse.data.success) {
|
||||
throw new Error('Passkey registration failed');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchPasskeys();
|
||||
refetchPasskeys();
|
||||
showAlert(t('success'), t('passkeyCreated') || 'Passkey created successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Error creating passkey:', error);
|
||||
// Extract error message from axios response or error object
|
||||
let errorMessage = t('passkeyCreationFailed') || 'Failed to create passkey. Please try again.';
|
||||
|
||||
if (error?.response?.data?.error) {
|
||||
// Backend error message
|
||||
errorMessage = error.response.data.error;
|
||||
} else if (error?.response?.data?.message) {
|
||||
errorMessage = error.response.data.message;
|
||||
} else if (error?.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// Check if this is a WebAuthn error that can be translated
|
||||
const translationKey = getWebAuthnErrorTranslationKey(errorMessage);
|
||||
if (translationKey) {
|
||||
errorMessage = t(translationKey) || errorMessage;
|
||||
}
|
||||
|
||||
showAlert(t('error'), errorMessage);
|
||||
},
|
||||
});
|
||||
|
||||
// Remove passkeys mutation
|
||||
const removePasskeysMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await axios.delete(`${API_URL}/settings/passkeys`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetchPasskeys();
|
||||
setShowRemoveModal(false);
|
||||
showAlert(t('success'), t('passkeysRemoved') || 'All passkeys have been removed');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Error removing passkeys:', error);
|
||||
showAlert(t('error'), t('passkeysRemoveFailed') || 'Failed to remove passkeys. Please try again.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreatePasskey = () => {
|
||||
// Check if we're in a secure context (HTTPS or localhost)
|
||||
// This is the most important check - WebAuthn requires secure context
|
||||
if (!window.isSecureContext) {
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
||||
if (!isLocalhost) {
|
||||
showAlert(t('error'), t('passkeyRequiresHttps') || 'WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if WebAuthn is supported
|
||||
// Check multiple ways to detect WebAuthn support
|
||||
const hasWebAuthn =
|
||||
typeof window.PublicKeyCredential !== 'undefined' ||
|
||||
(typeof navigator !== 'undefined' && 'credentials' in navigator && 'create' in navigator.credentials);
|
||||
|
||||
if (!hasWebAuthn) {
|
||||
showAlert(t('error'), t('passkeyWebAuthnNotSupported') || 'WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.');
|
||||
return;
|
||||
}
|
||||
|
||||
createPasskeyMutation.mutate();
|
||||
};
|
||||
|
||||
const handleRemovePasskeys = () => {
|
||||
removePasskeysMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -24,21 +161,147 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
|
||||
/>
|
||||
|
||||
{settings.loginEnabled && (
|
||||
<Box sx={{ mt: 2, maxWidth: 400 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('password')}
|
||||
type="password"
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => onChange('password', e.target.value)}
|
||||
helperText={
|
||||
settings.isPasswordSet
|
||||
? t('passwordHelper')
|
||||
: t('passwordSetHelper')
|
||||
<Box sx={{ mt: 2 }}>
|
||||
|
||||
{settings.passwordLoginAllowed !== false && (
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ mb: 2, maxWidth: 400 }}
|
||||
label={t('password')}
|
||||
type="password"
|
||||
value={settings.password || ''}
|
||||
onChange={(e) => onChange('password', e.target.value)}
|
||||
helperText={
|
||||
settings.isPasswordSet
|
||||
? t('passwordHelper')
|
||||
: t('passwordSetHelper')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!passkeysExist ? true : (settings.passwordLoginAllowed !== false)}
|
||||
onChange={(e) => onChange('passwordLoginAllowed', e.target.checked)}
|
||||
disabled={!settings.loginEnabled || !passkeysExist}
|
||||
/>
|
||||
}
|
||||
label={t('allowPasswordLogin') || 'Allow Password Login'}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('allowPasswordLoginHelper') || 'When disabled, password login is not available. You must have at least one passkey to disable password login.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.allowResetPassword !== false}
|
||||
onChange={(e) => onChange('allowResetPassword', e.target.checked)}
|
||||
disabled={!settings.loginEnabled}
|
||||
/>
|
||||
}
|
||||
label={t('allowResetPassword') || 'Allow Reset Password'}
|
||||
/>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('allowResetPasswordHelper') || 'When disabled, the reset password button will not be shown on the login page and the reset password API will be blocked.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, maxWidth: 400 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FingerprintIcon />}
|
||||
onClick={handleCreatePasskey}
|
||||
disabled={!settings.loginEnabled || createPasskeyMutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
{createPasskeyMutation.isPending
|
||||
? (t('creatingPasskey') || 'Creating...')
|
||||
: (t('createPasskey') || 'Create Passkey')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => setShowRemoveModal(true)}
|
||||
disabled={!settings.loginEnabled || !passkeysExist || removePasskeysMutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
{t('removePasskeys') || 'Remove All Passkeys'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
|
||||
{t('visitorUser') || 'Visitor User'}
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={settings.visitorUserEnabled !== false}
|
||||
onChange={(e) => onChange('visitorUserEnabled', e.target.checked)}
|
||||
disabled={!settings.loginEnabled}
|
||||
/>
|
||||
}
|
||||
label={t('enableVisitorUser') || 'Enable Visitor User'}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{settings.visitorUserEnabled !== false && (
|
||||
<>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t('visitorUserHelper') || 'Set a password for the Visitor User role. Users logging in with this password will have read-only access and cannot change settings.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
sx={{ mb: 2, maxWidth: 400 }}
|
||||
label={t('visitorPassword') || 'Visitor Password'}
|
||||
type="text"
|
||||
value={settings.visitorPassword || ''}
|
||||
onChange={(e) => onChange('visitorPassword', e.target.value)}
|
||||
helperText={
|
||||
settings.isVisitorPasswordSet
|
||||
? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.')
|
||||
: (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.')
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showRemoveModal}
|
||||
onClose={() => setShowRemoveModal(false)}
|
||||
onConfirm={handleRemovePasskeys}
|
||||
title={t('removePasskeysTitle') || 'Remove All Passkeys'}
|
||||
message={t('removePasskeysMessage') || 'Are you sure you want to remove all passkeys? This action cannot be undone.'}
|
||||
confirmText={t('remove') || 'Remove'}
|
||||
cancelText={t('cancel') || 'Cancel'}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<AlertModal
|
||||
open={alertOpen}
|
||||
onClose={() => setAlertOpen(false)}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import PasswordModal from '../PasswordModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface VisitorModeSettingsProps {
|
||||
visitorMode?: boolean;
|
||||
savedVisitorMode?: boolean;
|
||||
onChange: (field: string, value: string | number | boolean) => void;
|
||||
}
|
||||
|
||||
const VisitorModeSettings: React.FC<VisitorModeSettingsProps> = ({ visitorMode, savedVisitorMode: _savedVisitorMode, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [isVerifyingPassword, setIsVerifyingPassword] = useState(false);
|
||||
const [pendingVisitorMode, setPendingVisitorMode] = useState<boolean | null>(null);
|
||||
const [remainingWaitTime, setRemainingWaitTime] = useState(0);
|
||||
const [baseError, setBaseError] = useState('');
|
||||
|
||||
const handleVisitorModeChange = (checked: boolean) => {
|
||||
setPendingVisitorMode(checked);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
setShowPasswordModal(true);
|
||||
};
|
||||
|
||||
const handlePasswordConfirm = async (password: string) => {
|
||||
setIsVerifyingPassword(true);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/settings/verify-password`, { password });
|
||||
|
||||
// If successful, save the setting immediately
|
||||
if (pendingVisitorMode !== null) {
|
||||
// Save to backend
|
||||
await axios.post(`${API_URL}/settings`, { visitorMode: pendingVisitorMode });
|
||||
|
||||
// Invalidate settings query to ensure global state (VisitorModeContext) updates immediately
|
||||
await queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
|
||||
// Update parent state
|
||||
onChange('visitorMode', pendingVisitorMode);
|
||||
}
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
} catch (error: any) {
|
||||
console.error('Password verification failed:', error);
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
if (status === 429) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('tooManyAttempts') || 'Too many attempts.');
|
||||
} else if (status === 401) {
|
||||
const waitTimeMs = data.waitTime || 0;
|
||||
if (waitTimeMs > 0) {
|
||||
const seconds = Math.ceil(waitTimeMs / 1000);
|
||||
setRemainingWaitTime(seconds);
|
||||
setBaseError(t('incorrectPassword') || 'Incorrect password.');
|
||||
} else {
|
||||
setPasswordError(t('incorrectPassword') || 'Incorrect password');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('loginFailed') || 'Verification failed');
|
||||
}
|
||||
} else {
|
||||
setPasswordError(t('networkError' as any) || 'Network error');
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClosePasswordModal = () => {
|
||||
setShowPasswordModal(false);
|
||||
setPendingVisitorMode(null);
|
||||
setPasswordError('');
|
||||
setBaseError('');
|
||||
setRemainingWaitTime(0);
|
||||
};
|
||||
|
||||
// Effect to handle countdown
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (remainingWaitTime > 0) {
|
||||
// Update error message immediately
|
||||
const waitMsg = t('waitTimeMessage')?.replace('{time}', `${remainingWaitTime}s`) || `Please wait ${remainingWaitTime}s.`;
|
||||
setPasswordError(`${baseError} ${waitMsg}`);
|
||||
|
||||
interval = setInterval(() => {
|
||||
setRemainingWaitTime((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Countdown finished
|
||||
setPasswordError(baseError);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [remainingWaitTime, baseError, t]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={visitorMode ?? false}
|
||||
onChange={(e) => handleVisitorModeChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('visitorMode') || "Visitor Mode (Read-only)"}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5, ml: 4.5 }}>
|
||||
{t('visitorModeDescription') || "Read-only mode. Hidden videos will not be visible to visitors."}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={handleClosePasswordModal}
|
||||
onConfirm={handlePasswordConfirm}
|
||||
title={t('password' as any) || "Enter Website Password"}
|
||||
message={t('visitorModePasswordPrompt' as any) || "Please enter the website password to change Visitor Mode settings."}
|
||||
error={passwordError}
|
||||
isLoading={isVerifyingPassword}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitorModeSettings;
|
||||
@@ -60,7 +60,12 @@ const DEFAULT_CONFIG = `# yt-dlp Configuration File
|
||||
# Note: -f filters may not work reliably with all video sources
|
||||
# Use -S above for more consistent results
|
||||
|
||||
# Download best quality (default behavior)
|
||||
# Download best quality (Recommended for 4K/8K)
|
||||
# Note: This will likely use VP9/AV1 codecs which are best for high resolution
|
||||
# -S res:2160
|
||||
|
||||
# Download best quality using format selection (Legacy)
|
||||
# Note: This may be limited to 1080p due to MP4 compatibility requirements
|
||||
# -f bestvideo*+bestaudio/best
|
||||
|
||||
# Limit to 1080p maximum using filter
|
||||
|
||||
@@ -14,6 +14,17 @@ vi.mock('../../../contexts/SnackbarContext', () => ({
|
||||
useSnackbar: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
loginRequired: false,
|
||||
checkingAuth: false,
|
||||
userRole: 'admin',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useCloudflareStatus', () => ({
|
||||
useCloudflareStatus: vi.fn(),
|
||||
}));
|
||||
@@ -33,7 +44,7 @@ describe('CloudflareSettings', () => {
|
||||
});
|
||||
|
||||
it('should render switch and fields', () => {
|
||||
render(<CloudflareSettings enabled={true} token="test-token" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="test-token" onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText(/enableCloudflaredTunnel/i)).toBeChecked();
|
||||
expect(screen.getByLabelText(/cloudflaredToken/i)).toHaveValue('test-token');
|
||||
@@ -41,7 +52,7 @@ describe('CloudflareSettings', () => {
|
||||
|
||||
it('should update enable state on switch toggle', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CloudflareSettings enabled={false} token="" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={false} token="" onChange={mockOnChange} />);
|
||||
|
||||
const switchControl = screen.getByRole('switch', { name: /enableCloudflaredTunnel/i });
|
||||
await user.click(switchControl);
|
||||
@@ -51,7 +62,7 @@ describe('CloudflareSettings', () => {
|
||||
|
||||
it('should update token on change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CloudflareSettings enabled={true} token="" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="" onChange={mockOnChange} />);
|
||||
|
||||
const tokenInput = screen.getByLabelText(/cloudflaredToken/i);
|
||||
await user.type(tokenInput, 'new-token');
|
||||
@@ -65,7 +76,7 @@ describe('CloudflareSettings', () => {
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
render(<CloudflareSettings enabled={true} token="test-token" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="test-token" onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByText('running')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://test.trycloudflare.com')).toBeInTheDocument();
|
||||
@@ -89,7 +100,7 @@ describe('CloudflareSettings', () => {
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
render(<CloudflareSettings enabled={true} token="test-token" visitorMode={false} onChange={mockOnChange} />);
|
||||
render(<CloudflareSettings enabled={true} token="test-token" onChange={mockOnChange} />);
|
||||
|
||||
const urlElement = screen.getByText('https://test.trycloudflare.com');
|
||||
await user.click(urlElement);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import SecuritySettings from '../SecuritySettings';
|
||||
@@ -8,12 +9,30 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
useLanguage: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const render = (ui: React.ReactElement) => {
|
||||
const queryClient = createTestQueryClient();
|
||||
return rtlRender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('SecuritySettings', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const defaultSettings: any = {
|
||||
loginEnabled: false,
|
||||
password: '',
|
||||
isPasswordSet: false,
|
||||
visitorPassword: '',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -25,15 +44,24 @@ describe('SecuritySettings', () => {
|
||||
|
||||
expect(screen.getByLabelText('enableLogin')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('password')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('visitorPassword')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show password field when enabled', () => {
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText('password')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('visitorPassword')).toBeInTheDocument();
|
||||
expect(screen.getByText('passwordSetHelper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show visitor password field when login is enabled', () => {
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByLabelText('visitorPassword')).toBeInTheDocument();
|
||||
expect(screen.getByText('visitorPasswordHelper')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle switch change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SecuritySettings settings={defaultSettings} onChange={mockOnChange} />);
|
||||
@@ -42,6 +70,8 @@ describe('SecuritySettings', () => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith('loginEnabled', true);
|
||||
});
|
||||
|
||||
// Visitor mode switch has been removed - visitor password is always visible when login is enabled
|
||||
|
||||
it('should handle password change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
@@ -50,4 +80,15 @@ describe('SecuritySettings', () => {
|
||||
await user.type(input, 'secret');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('password', 's');
|
||||
});
|
||||
|
||||
it('should handle visitor password change', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SecuritySettings settings={{ ...defaultSettings, loginEnabled: true }} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByLabelText('visitorPassword');
|
||||
await user.type(input, 'guest');
|
||||
expect(mockOnChange).toHaveBeenCalledWith('visitorPassword', 'g');
|
||||
});
|
||||
|
||||
// Visitor mode switch has been removed - this test is no longer applicable
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Box, CardContent, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Video } from '../../types';
|
||||
import { formatDate } from '../../utils/formatUtils';
|
||||
import { formatRelativeDownloadTime } from '../../utils/formatUtils';
|
||||
import { VideoCardCollectionInfo } from '../../utils/videoCardUtils';
|
||||
|
||||
interface VideoCardContentProps {
|
||||
@@ -72,7 +72,7 @@ export const VideoCardContent: React.FC<VideoCardContentProps> = ({
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(video.date)}
|
||||
{formatRelativeDownloadTime(video.addedAt, video.date, t)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../../contexts/LanguageContext', () => ({
|
||||
|
||||
vi.mock('../../../utils/formatUtils', () => ({
|
||||
formatDate: () => '2023-01-01',
|
||||
formatRelativeDownloadTime: () => '2023-01-01',
|
||||
}));
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useCloudStorageUrl } from '../../hooks/useCloudStorageUrl';
|
||||
import { Video } from '../../types';
|
||||
import { formatDate, formatDuration } from '../../utils/formatUtils';
|
||||
@@ -107,7 +107,8 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
|
||||
onAddToCollection
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const [hoveredVideoId, setHoveredVideoId] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -162,7 +163,7 @@ const UpNextSidebar: React.FC<UpNextSidebarProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && !visitorMode && (
|
||||
{hoveredVideoId === relatedVideo.id && !isMobile && !isTouch && !isVisitor && (
|
||||
<Tooltip title={t('addToCollection')} disableHoverListener={isTouch}>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Check, Close, Edit, ExpandLess, ExpandMore } from '@mui/icons-material'
|
||||
import { Box, Button, TextField, Tooltip, Typography, useMediaQuery } from '@mui/material';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
interface EditableTitleProps {
|
||||
title: string;
|
||||
@@ -11,7 +11,8 @@ interface EditableTitleProps {
|
||||
|
||||
const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const [isEditingTitle, setIsEditingTitle] = useState<boolean>(false);
|
||||
const [editedTitle, setEditedTitle] = useState<string>('');
|
||||
@@ -106,7 +107,7 @@ const EditableTitle: React.FC<EditableTitleProps> = ({ title, onSave }) => {
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Tooltip title={t('editTitle')} disableHoverListener={isTouch}>
|
||||
<Button
|
||||
size="small"
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../../../contexts/SnackbarContext';
|
||||
import { useVideo } from '../../../contexts/VideoContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useCloudStorageUrl } from '../../../hooks/useCloudStorageUrl';
|
||||
import { useShareVideo } from '../../../hooks/useShareVideo';
|
||||
import { Video } from '../../../types'; // Add imports
|
||||
@@ -29,7 +29,8 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
|
||||
const { t } = useLanguage();
|
||||
const { handleShare } = useShareVideo(video);
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const { incrementView } = useVideo();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -247,7 +248,7 @@ const VideoActionButtons: React.FC<VideoActionButtonsProps> = ({
|
||||
<Share />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<>
|
||||
{onToggleVisibility && (
|
||||
<Tooltip title={video.visibility === 0 ? t('showVideo') : t('hideVideo')} disableHoverListener={isTouch}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Notifications, NotificationsActive } from '@mui/icons-material';
|
||||
import { Avatar, Box, IconButton, Tooltip, Typography, useMediaQuery } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
interface VideoAuthorInfoProps {
|
||||
author: string;
|
||||
@@ -37,9 +37,10 @@ const VideoAuthorInfo: React.FC<VideoAuthorInfoProps> = ({
|
||||
onUnsubscribe
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const showSubscribeButton = (source === 'youtube' || source === 'bilibili') && !visitorMode;
|
||||
const showSubscribeButton = (source === 'youtube' || source === 'bilibili') && !isVisitor;
|
||||
|
||||
const handleSubscribeClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Add, Cast, Delete, MoreVert, Share, Visibility, VisibilityOff } from '@
|
||||
import { Button, IconButton, Menu, Stack, Tooltip, useMediaQuery } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../../contexts/LanguageContext';
|
||||
import { useVisitorMode } from '../../../contexts/VisitorModeContext';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
interface VideoKebabMenuButtonsProps {
|
||||
onPlayWith: (anchor: HTMLElement) => void;
|
||||
@@ -26,7 +26,8 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
|
||||
sx
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const [kebabMenuAnchor, setKebabMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
@@ -140,7 +141,7 @@ const VideoKebabMenuButtons: React.FC<VideoKebabMenuButtonsProps> = ({
|
||||
<Share />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<>
|
||||
{onToggleVisibility && (
|
||||
<Tooltip title={video?.visibility === 0 ? t('showVideo') : t('hideVideo')} disableHoverListener={isTouch}>
|
||||
|
||||
@@ -17,8 +17,8 @@ vi.mock('../../VideoCard', () => ({
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({ visitorMode: false })
|
||||
vi.mock('../../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({ userRole: 'admin' })
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/useCloudStorageUrl', () => ({
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('CollectionCard', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Test Collection/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 videos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders collection creation date', () => {
|
||||
@@ -131,7 +131,7 @@ describe('CollectionCard', () => {
|
||||
);
|
||||
|
||||
// Should show folder icon (via Material-UI icon)
|
||||
expect(screen.getByText(/0 videos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays up to 4 thumbnails in grid', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthProvider } from '../../contexts/AuthContext';
|
||||
import Header from '../Header';
|
||||
|
||||
// Mock contexts
|
||||
@@ -35,10 +36,16 @@ vi.mock('../../contexts/CollectionContext', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../contexts/VisitorModeContext', () => ({
|
||||
useVisitorMode: () => ({
|
||||
visitorMode: false,
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
loginRequired: false,
|
||||
checkingAuth: false,
|
||||
userRole: 'admin',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
AuthProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock child components to avoid context dependency issues
|
||||
@@ -92,11 +99,13 @@ describe('Header', () => {
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Header {...defaultProps} {...props} />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<Header {...defaultProps} {...props} />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
@@ -124,14 +133,14 @@ describe('Header', () => {
|
||||
// and fall back to the default name. We verify the component renders correctly either way.
|
||||
const logo = screen.getByAltText('MyTube Logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
|
||||
|
||||
// Wait for the component to stabilize after async operations
|
||||
await waitFor(() => {
|
||||
// The title should be either "TestTube" (if settings succeeds) or "MyTube" (default)
|
||||
const title = screen.queryByText('TestTube') || screen.queryByText('MyTube');
|
||||
expect(title).toBeInTheDocument();
|
||||
}, { timeout: 2000 });
|
||||
|
||||
|
||||
// Logo should always be present
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,16 @@ vi.mock('../../contexts/LanguageContext');
|
||||
vi.mock('../../contexts/CollectionContext');
|
||||
vi.mock('../../contexts/SnackbarContext');
|
||||
vi.mock('../../contexts/VideoContext');
|
||||
vi.mock('../../contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
loginRequired: false,
|
||||
checkingAuth: false,
|
||||
userRole: 'admin',
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockVideo = {
|
||||
id: '123',
|
||||
|
||||
@@ -8,7 +8,8 @@ interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
loginRequired: boolean;
|
||||
checkingAuth: boolean;
|
||||
login: () => void;
|
||||
userRole: 'admin' | 'visitor' | null;
|
||||
login: (role?: 'admin' | 'visitor') => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -16,6 +17,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [userRole, setUserRole] = useState<'admin' | 'visitor' | null>(null);
|
||||
const [loginRequired, setLoginRequired] = useState<boolean>(true); // Assume required until checked
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -25,21 +27,36 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Check if login is enabled in settings
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
const response = await axios.get(`${API_URL}/settings`, {
|
||||
withCredentials: true
|
||||
});
|
||||
const { loginEnabled, isPasswordSet } = response.data;
|
||||
|
||||
// Login is required only if enabled AND a password is set
|
||||
// Login is required if loginEnabled is true (regardless of password or passkey)
|
||||
if (!loginEnabled || !isPasswordSet) {
|
||||
setLoginRequired(false);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setLoginRequired(true);
|
||||
// Check if already authenticated in this session
|
||||
const sessionAuth = sessionStorage.getItem('mytube_authenticated');
|
||||
if (sessionAuth === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
// Check if already authenticated via HTTP-only cookie
|
||||
// Read role from cookie (non-HTTP-only cookie set by backend)
|
||||
const roleCookie = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('mytube_role='));
|
||||
|
||||
if (roleCookie) {
|
||||
const role = roleCookie.split('=')[1] as 'admin' | 'visitor';
|
||||
if (role === 'admin' || role === 'visitor') {
|
||||
setIsAuthenticated(true);
|
||||
setUserRole(role);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setUserRole(null);
|
||||
}
|
||||
} else {
|
||||
// No role cookie means not authenticated
|
||||
setIsAuthenticated(false);
|
||||
setUserRole(null);
|
||||
}
|
||||
}
|
||||
return response.data;
|
||||
@@ -50,19 +67,40 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
});
|
||||
|
||||
const login = () => {
|
||||
const login = (role?: 'admin' | 'visitor') => {
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem('mytube_authenticated', 'true');
|
||||
if (role) {
|
||||
setUserRole(role);
|
||||
}
|
||||
// Token is now stored in HTTP-only cookie by backend, no need to store it here
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
// Clear local state immediately
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem('mytube_authenticated');
|
||||
setUserRole(null);
|
||||
|
||||
// Clear role cookie from frontend (it's not HTTP-only, so we can clear it)
|
||||
// This prevents the auth check from seeing the cookie before backend clears it
|
||||
document.cookie = 'mytube_role=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||||
|
||||
try {
|
||||
// Call backend logout endpoint to clear HTTP-only cookies
|
||||
await axios.post(`${API_URL}/settings/logout`, {}, {
|
||||
withCredentials: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Continue with logout even if backend call fails
|
||||
}
|
||||
|
||||
// Invalidate and refetch auth settings to ensure fresh auth state
|
||||
queryClient.invalidateQueries({ queryKey: ['authSettings'] });
|
||||
queryClient.refetchQueries({ queryKey: ['authSettings'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, login, logout }}>
|
||||
<AuthContext.Provider value={{ isAuthenticated, loginRequired, checkingAuth, userRole, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { createContext, useContext, useEffect, useMemo, useRef, useState
|
||||
import { Video } from '../types';
|
||||
import { useLanguage } from './LanguageContext';
|
||||
import { useSnackbar } from './SnackbarContext';
|
||||
import { useVisitorMode } from './VisitorModeContext';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const MAX_SEARCH_RESULTS = 200; // Maximum number of search results to keep in memory
|
||||
@@ -51,7 +51,8 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { t } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
|
||||
// Videos Query
|
||||
const { data: videosRaw = [], isLoading: videosLoading, error: videosError, refetch: refetchVideos } = useQuery({
|
||||
@@ -75,11 +76,11 @@ export const VideoProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
|
||||
// Filter invisible videos when in visitor mode
|
||||
const videos = useMemo(() => {
|
||||
if (visitorMode) {
|
||||
if (isVisitor) {
|
||||
return videosRaw.filter(video => (video.visibility ?? 1) === 1);
|
||||
}
|
||||
return videosRaw;
|
||||
}, [videosRaw, visitorMode]);
|
||||
}, [videosRaw, isVisitor]);
|
||||
|
||||
// Settings Query (tags and showYoutubeSearch)
|
||||
const { data: settingsData } = useQuery({
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface VisitorModeContextType {
|
||||
visitorMode: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const VisitorModeContext = createContext<VisitorModeContextType>({
|
||||
visitorMode: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { data: settingsData, isLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30000, // Refetch every 30 seconds (reduced frequency)
|
||||
staleTime: 10000, // Consider data fresh for 10 seconds
|
||||
gcTime: 10 * 60 * 1000, // Garbage collect after 10 minutes
|
||||
});
|
||||
|
||||
const visitorMode = settingsData?.visitorMode === true;
|
||||
|
||||
return (
|
||||
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
|
||||
{children}
|
||||
</VisitorModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useVisitorMode = () => {
|
||||
const context = useContext(VisitorModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useVisitorMode must be used within a VisitorModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,143 +1,297 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDate, formatDuration, formatSize, parseDuration } from '../formatUtils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatRelativeDownloadTime,
|
||||
formatSize,
|
||||
parseDuration,
|
||||
} from "../formatUtils";
|
||||
|
||||
describe('formatUtils', () => {
|
||||
describe('parseDuration', () => {
|
||||
it('should return 0 for undefined', () => {
|
||||
expect(parseDuration(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return number as-is', () => {
|
||||
expect(parseDuration(100)).toBe(100);
|
||||
expect(parseDuration(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should parse HH:MM:SS format', () => {
|
||||
expect(parseDuration('1:30:45')).toBe(5445); // 1*3600 + 30*60 + 45 = 3600 + 1800 + 45
|
||||
expect(parseDuration('0:5:30')).toBe(330); // 0*3600 + 5*60 + 30 = 0 + 300 + 30
|
||||
expect(parseDuration('2:0:0')).toBe(7200); // 2*3600 + 0*60 + 0 = 7200
|
||||
});
|
||||
|
||||
it('should parse MM:SS format', () => {
|
||||
expect(parseDuration('5:30')).toBe(330); // 5*60 + 30
|
||||
expect(parseDuration('10:15')).toBe(615); // 10*60 + 15
|
||||
expect(parseDuration('0:45')).toBe(45);
|
||||
});
|
||||
|
||||
it('should parse numeric string', () => {
|
||||
expect(parseDuration('100')).toBe(100);
|
||||
expect(parseDuration('0')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for invalid string', () => {
|
||||
expect(parseDuration('invalid')).toBe(0);
|
||||
// 'abc:def' will be parsed as NaN for each part, but the function
|
||||
// will try parseInt on the whole string which also returns NaN -> 0
|
||||
expect(parseDuration('abc:def')).toBe(0);
|
||||
expect(parseDuration('not-a-number')).toBe(0);
|
||||
});
|
||||
describe("formatUtils", () => {
|
||||
describe("parseDuration", () => {
|
||||
it("should return 0 for undefined", () => {
|
||||
expect(parseDuration(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('should return 00:00 for undefined', () => {
|
||||
expect(formatDuration(undefined)).toBe('00:00');
|
||||
});
|
||||
|
||||
it('should return formatted string as-is if already formatted', () => {
|
||||
expect(formatDuration('1:30:45')).toBe('1:30:45');
|
||||
expect(formatDuration('5:30')).toBe('5:30');
|
||||
});
|
||||
|
||||
it('should format seconds to MM:SS', () => {
|
||||
expect(formatDuration(65)).toBe('1:05'); // 1 minute 5 seconds
|
||||
expect(formatDuration(125)).toBe('2:05'); // 2 minutes 5 seconds
|
||||
expect(formatDuration(45)).toBe('0:45'); // 45 seconds
|
||||
expect(formatDuration(0)).toBe('00:00');
|
||||
});
|
||||
|
||||
it('should format seconds to H:MM:SS for hours', () => {
|
||||
expect(formatDuration(3665)).toBe('1:01:05'); // 1 hour 1 minute 5 seconds
|
||||
expect(formatDuration(3600)).toBe('1:00:00'); // 1 hour
|
||||
expect(formatDuration(7325)).toBe('2:02:05'); // 2 hours 2 minutes 5 seconds
|
||||
});
|
||||
|
||||
it('should format numeric string', () => {
|
||||
expect(formatDuration('65')).toBe('1:05');
|
||||
expect(formatDuration('3665')).toBe('1:01:05');
|
||||
});
|
||||
|
||||
it('should return 00:00 for invalid input', () => {
|
||||
expect(formatDuration('invalid')).toBe('00:00');
|
||||
expect(formatDuration(NaN)).toBe('00:00');
|
||||
});
|
||||
it("should return number as-is", () => {
|
||||
expect(parseDuration(100)).toBe(100);
|
||||
expect(parseDuration(0)).toBe(0);
|
||||
});
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('should return "0 B" for undefined', () => {
|
||||
expect(formatSize(undefined)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('should format bytes', () => {
|
||||
expect(formatSize(0)).toBe('0 B');
|
||||
expect(formatSize(500)).toBe('500 B');
|
||||
expect(formatSize(1023)).toBe('1023 B');
|
||||
});
|
||||
|
||||
it('should format kilobytes', () => {
|
||||
expect(formatSize(1024)).toBe('1 KB');
|
||||
expect(formatSize(1536)).toBe('1.5 KB');
|
||||
expect(formatSize(2048)).toBe('2 KB');
|
||||
expect(formatSize(10240)).toBe('10 KB');
|
||||
});
|
||||
|
||||
it('should format megabytes', () => {
|
||||
expect(formatSize(1048576)).toBe('1 MB'); // 1024 * 1024
|
||||
expect(formatSize(1572864)).toBe('1.5 MB');
|
||||
expect(formatSize(5242880)).toBe('5 MB');
|
||||
});
|
||||
|
||||
it('should format gigabytes', () => {
|
||||
expect(formatSize(1073741824)).toBe('1 GB'); // 1024^3
|
||||
expect(formatSize(2147483648)).toBe('2 GB');
|
||||
});
|
||||
|
||||
it('should format terabytes', () => {
|
||||
expect(formatSize(1099511627776)).toBe('1 TB'); // 1024^4
|
||||
});
|
||||
|
||||
it('should format numeric string', () => {
|
||||
expect(formatSize('1024')).toBe('1 KB');
|
||||
expect(formatSize('1048576')).toBe('1 MB');
|
||||
});
|
||||
|
||||
it('should return "0 B" for invalid input', () => {
|
||||
expect(formatSize('invalid')).toBe('0 B');
|
||||
expect(formatSize(NaN)).toBe('0 B');
|
||||
});
|
||||
it("should parse HH:MM:SS format", () => {
|
||||
expect(parseDuration("1:30:45")).toBe(5445); // 1*3600 + 30*60 + 45 = 3600 + 1800 + 45
|
||||
expect(parseDuration("0:5:30")).toBe(330); // 0*3600 + 5*60 + 30 = 0 + 300 + 30
|
||||
expect(parseDuration("2:0:0")).toBe(7200); // 2*3600 + 0*60 + 0 = 7200
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('should return "Unknown date" for undefined', () => {
|
||||
expect(formatDate(undefined)).toBe('Unknown date');
|
||||
});
|
||||
|
||||
it('should return "Unknown date" for invalid length', () => {
|
||||
expect(formatDate('202301')).toBe('Unknown date');
|
||||
expect(formatDate('202301011')).toBe('Unknown date');
|
||||
expect(formatDate('2023')).toBe('Unknown date');
|
||||
});
|
||||
|
||||
it('should format YYYYMMDD to YYYY-MM-DD', () => {
|
||||
expect(formatDate('20230101')).toBe('2023-01-01');
|
||||
expect(formatDate('20231225')).toBe('2023-12-25');
|
||||
expect(formatDate('20200101')).toBe('2020-01-01');
|
||||
expect(formatDate('20230228')).toBe('2023-02-28');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(formatDate('19991231')).toBe('1999-12-31');
|
||||
expect(formatDate('20991231')).toBe('2099-12-31');
|
||||
});
|
||||
it("should parse MM:SS format", () => {
|
||||
expect(parseDuration("5:30")).toBe(330); // 5*60 + 30
|
||||
expect(parseDuration("10:15")).toBe(615); // 10*60 + 15
|
||||
expect(parseDuration("0:45")).toBe(45);
|
||||
});
|
||||
|
||||
it("should parse numeric string", () => {
|
||||
expect(parseDuration("100")).toBe(100);
|
||||
expect(parseDuration("0")).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for invalid string", () => {
|
||||
expect(parseDuration("invalid")).toBe(0);
|
||||
// 'abc:def' will be parsed as NaN for each part, but the function
|
||||
// will try parseInt on the whole string which also returns NaN -> 0
|
||||
expect(parseDuration("abc:def")).toBe(0);
|
||||
expect(parseDuration("not-a-number")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("should return 00:00 for undefined", () => {
|
||||
expect(formatDuration(undefined)).toBe("00:00");
|
||||
});
|
||||
|
||||
it("should return formatted string as-is if already formatted", () => {
|
||||
expect(formatDuration("1:30:45")).toBe("1:30:45");
|
||||
expect(formatDuration("5:30")).toBe("5:30");
|
||||
});
|
||||
|
||||
it("should format seconds to MM:SS", () => {
|
||||
expect(formatDuration(65)).toBe("1:05"); // 1 minute 5 seconds
|
||||
expect(formatDuration(125)).toBe("2:05"); // 2 minutes 5 seconds
|
||||
expect(formatDuration(45)).toBe("0:45"); // 45 seconds
|
||||
expect(formatDuration(0)).toBe("00:00");
|
||||
});
|
||||
|
||||
it("should format seconds to H:MM:SS for hours", () => {
|
||||
expect(formatDuration(3665)).toBe("1:01:05"); // 1 hour 1 minute 5 seconds
|
||||
expect(formatDuration(3600)).toBe("1:00:00"); // 1 hour
|
||||
expect(formatDuration(7325)).toBe("2:02:05"); // 2 hours 2 minutes 5 seconds
|
||||
});
|
||||
|
||||
it("should format numeric string", () => {
|
||||
expect(formatDuration("65")).toBe("1:05");
|
||||
expect(formatDuration("3665")).toBe("1:01:05");
|
||||
});
|
||||
|
||||
it("should return 00:00 for invalid input", () => {
|
||||
expect(formatDuration("invalid")).toBe("00:00");
|
||||
expect(formatDuration(NaN)).toBe("00:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSize", () => {
|
||||
it('should return "0 B" for undefined', () => {
|
||||
expect(formatSize(undefined)).toBe("0 B");
|
||||
});
|
||||
|
||||
it("should format bytes", () => {
|
||||
expect(formatSize(0)).toBe("0 B");
|
||||
expect(formatSize(500)).toBe("500 B");
|
||||
expect(formatSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("should format kilobytes", () => {
|
||||
expect(formatSize(1024)).toBe("1 KB");
|
||||
expect(formatSize(1536)).toBe("1.5 KB");
|
||||
expect(formatSize(2048)).toBe("2 KB");
|
||||
expect(formatSize(10240)).toBe("10 KB");
|
||||
});
|
||||
|
||||
it("should format megabytes", () => {
|
||||
expect(formatSize(1048576)).toBe("1 MB"); // 1024 * 1024
|
||||
expect(formatSize(1572864)).toBe("1.5 MB");
|
||||
expect(formatSize(5242880)).toBe("5 MB");
|
||||
});
|
||||
|
||||
it("should format gigabytes", () => {
|
||||
expect(formatSize(1073741824)).toBe("1 GB"); // 1024^3
|
||||
expect(formatSize(2147483648)).toBe("2 GB");
|
||||
});
|
||||
|
||||
it("should format terabytes", () => {
|
||||
expect(formatSize(1099511627776)).toBe("1 TB"); // 1024^4
|
||||
});
|
||||
|
||||
it("should format numeric string", () => {
|
||||
expect(formatSize("1024")).toBe("1 KB");
|
||||
expect(formatSize("1048576")).toBe("1 MB");
|
||||
});
|
||||
|
||||
it('should return "0 B" for invalid input', () => {
|
||||
expect(formatSize("invalid")).toBe("0 B");
|
||||
expect(formatSize(NaN)).toBe("0 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it('should return "Unknown date" for undefined', () => {
|
||||
expect(formatDate(undefined)).toBe("Unknown date");
|
||||
});
|
||||
|
||||
it('should return "Unknown date" for invalid length', () => {
|
||||
expect(formatDate("202301")).toBe("Unknown date");
|
||||
expect(formatDate("202301011")).toBe("Unknown date");
|
||||
expect(formatDate("2023")).toBe("Unknown date");
|
||||
});
|
||||
|
||||
it("should format YYYYMMDD to YYYY-MM-DD", () => {
|
||||
expect(formatDate("20230101")).toBe("2023-01-01");
|
||||
expect(formatDate("20231225")).toBe("2023-12-25");
|
||||
expect(formatDate("20200101")).toBe("2020-01-01");
|
||||
expect(formatDate("20230228")).toBe("2023-02-28");
|
||||
});
|
||||
|
||||
it("should handle edge cases", () => {
|
||||
expect(formatDate("19991231")).toBe("1999-12-31");
|
||||
expect(formatDate("20991231")).toBe("2099-12-31");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeDownloadTime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const mockTranslation = (
|
||||
key: string,
|
||||
replacements?: Record<string, string | number>
|
||||
) => {
|
||||
const translations: Record<string, string> = {
|
||||
justNow: "Just now",
|
||||
hoursAgo: `${replacements?.hours || 0} hours ago`,
|
||||
today: "Today",
|
||||
thisWeek: "This week",
|
||||
weeksAgo: `${replacements?.weeks || 0} weeks ago`,
|
||||
unknownDate: "Unknown date",
|
||||
};
|
||||
return translations[key] || key;
|
||||
};
|
||||
|
||||
it('should return "Just now" for less than 1 hour', () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const thirtyMinutesAgo = new Date("2023-01-01T11:30:00Z").toISOString();
|
||||
expect(
|
||||
formatRelativeDownloadTime(thirtyMinutesAgo, undefined, mockTranslation)
|
||||
).toBe("Just now");
|
||||
});
|
||||
|
||||
it('should return "X hours ago" for 1-5 hours', () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const twoHoursAgo = new Date("2023-01-01T10:00:00Z").toISOString();
|
||||
expect(
|
||||
formatRelativeDownloadTime(twoHoursAgo, undefined, mockTranslation)
|
||||
).toBe("2 hours ago");
|
||||
});
|
||||
|
||||
it('should return "Today" for 5-24 hours', () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const tenHoursAgo = new Date("2023-01-01T02:00:00Z").toISOString();
|
||||
expect(
|
||||
formatRelativeDownloadTime(tenHoursAgo, undefined, mockTranslation)
|
||||
).toBe("Today");
|
||||
});
|
||||
|
||||
it('should return "This week" for 1-7 days', () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const threeDaysAgo = new Date("2022-12-29T12:00:00Z").toISOString();
|
||||
expect(
|
||||
formatRelativeDownloadTime(threeDaysAgo, undefined, mockTranslation)
|
||||
).toBe("This week");
|
||||
});
|
||||
|
||||
it('should return "X weeks ago" for 1-4 weeks', () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const twoWeeksAgo = new Date("2022-12-18T12:00:00Z").toISOString();
|
||||
expect(
|
||||
formatRelativeDownloadTime(twoWeeksAgo, undefined, mockTranslation)
|
||||
).toBe("2 weeks ago");
|
||||
});
|
||||
|
||||
it("should return formatted date for > 4 weeks", () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const sixWeeksAgo = new Date("2022-11-20T12:00:00Z").toISOString();
|
||||
const result = formatRelativeDownloadTime(
|
||||
sixWeeksAgo,
|
||||
"20221120",
|
||||
mockTranslation
|
||||
);
|
||||
expect(result).toBe("2022-11-20");
|
||||
});
|
||||
|
||||
it("should use originalDate when provided for > 4 weeks", () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const sixWeeksAgo = new Date("2022-11-20T12:00:00Z").toISOString();
|
||||
expect(
|
||||
formatRelativeDownloadTime(sixWeeksAgo, "20221120", mockTranslation)
|
||||
).toBe("2022-11-20");
|
||||
});
|
||||
|
||||
it('should fallback to "Unknown date" when no timestamp provided', () => {
|
||||
expect(
|
||||
formatRelativeDownloadTime(undefined, undefined, mockTranslation)
|
||||
).toBe("Unknown date");
|
||||
});
|
||||
|
||||
it("should use originalDate when no timestamp provided", () => {
|
||||
expect(
|
||||
formatRelativeDownloadTime(undefined, "20230101", mockTranslation)
|
||||
).toBe("2023-01-01");
|
||||
});
|
||||
|
||||
it("should fallback to English when no translation function provided", () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
const thirtyMinutesAgo = new Date("2023-01-01T11:30:00Z").toISOString();
|
||||
expect(formatRelativeDownloadTime(thirtyMinutesAgo)).toBe("Just now");
|
||||
});
|
||||
|
||||
it("should handle invalid date", () => {
|
||||
expect(
|
||||
formatRelativeDownloadTime("invalid-date", undefined, mockTranslation)
|
||||
).toBe("Unknown date");
|
||||
});
|
||||
|
||||
it("should use originalDate when date is invalid", () => {
|
||||
expect(
|
||||
formatRelativeDownloadTime("invalid-date", "20230101", mockTranslation)
|
||||
).toBe("2023-01-01");
|
||||
});
|
||||
|
||||
it("should format date in UTC to avoid timezone issues", () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
// Use a date that could be affected by timezone (midnight UTC)
|
||||
const sixWeeksAgo = new Date("2022-11-20T00:00:00Z").toISOString();
|
||||
// Should format as 2022-11-20 regardless of system timezone
|
||||
const result = formatRelativeDownloadTime(
|
||||
sixWeeksAgo,
|
||||
undefined,
|
||||
mockTranslation
|
||||
);
|
||||
expect(result).toBe("2022-11-20");
|
||||
});
|
||||
|
||||
it("should handle date formatting across timezone boundaries", () => {
|
||||
const now = new Date("2023-01-01T12:00:00Z");
|
||||
vi.setSystemTime(now);
|
||||
// Test with a date near midnight UTC to catch timezone edge cases
|
||||
const sixWeeksAgo = new Date("2022-11-20T23:59:59Z").toISOString();
|
||||
const result = formatRelativeDownloadTime(
|
||||
sixWeeksAgo,
|
||||
undefined,
|
||||
mockTranslation
|
||||
);
|
||||
expect(result).toBe("2022-11-20");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user