Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
97
CHANGELOG.md
97
CHANGELOG.md
@@ -1,6 +1,103 @@
|
||||
# Change Log
|
||||
|
||||
|
||||
## v1.7.30 (2026-01-04)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Update mock SettingsPage test to include refetch (eeac567)
|
||||
|
||||
### Style
|
||||
|
||||
- style: Improve comments for YtDlpSettings file (e7bdf18)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Add file_location test and mock settings in ytdlpVideo (a5e82b9)
|
||||
|
||||
## v1.7.29 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Enable visitor user with password option (ccd2729)
|
||||
- feat: enhance visitor mode (e18f49d)
|
||||
- feat: enhance visitor mode (13de853)
|
||||
- feat: enhance visitor mode (76d4269)
|
||||
- feat: Add visitor mode in LoginPage component (44b2454)
|
||||
|
||||
### Test
|
||||
|
||||
- test: Add role to response in passwordController tests (a9f7864)
|
||||
|
||||
## v1.7.28 (2026-01-03)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve m3u8 URL selection strategy (3717296)
|
||||
|
||||
## v1.7.27 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add endpoint for retrieving reset password cooldown (092a79f)
|
||||
- feat: Add WebAuthn error translations (9296390)
|
||||
|
||||
## v1.7.26 (2026-01-03)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add script to reset password securely (1b9451b)
|
||||
- feat: Add allowResetPassword setting and UI components (9968268)
|
||||
- feat: Add password login permission handling (ce544ff)
|
||||
|
||||
## v1.7.25 (2026-01-02)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: add passkey feature (6fdfa90)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Update formatUtils to use formatRelativeDownloadTime function (c9657ba)
|
||||
|
||||
## v1.7.24 (2026-01-01)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Explicitly preserve network-related options (90a2445)
|
||||
|
||||
## v1.7.23 (2026-01-01)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add logic to refresh thumbnail with random timestamp (6bbb40e)
|
||||
- feat: Add reset password route and update dependencies (c00b552)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add reset password route (845e184)
|
||||
|
||||
### Fix
|
||||
|
||||
- fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities (b0428b9)
|
||||
|
||||
## v1.7.22 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add risk command scanning for hook uploads (c32fa3e)
|
||||
|
||||
### Refactor
|
||||
|
||||
- refactor: Improve handling of absolute paths in security functions (351f187)
|
||||
|
||||
## v1.7.21 (2025-12-30)
|
||||
|
||||
### Feat
|
||||
|
||||
- feat: Add hook functionality for task lifecycle (6f1a1cd)
|
||||
- feat: add task hooks (8ac9e99)
|
||||
|
||||
## v1.7.20 (2025-12-30)
|
||||
|
||||
### Chore
|
||||
|
||||
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.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.30",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -30,6 +34,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
@@ -628,6 +633,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -791,6 +802,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -814,6 +831,165 @@
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
|
||||
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz",
|
||||
"integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz",
|
||||
"integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
|
||||
"integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz",
|
||||
"integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz",
|
||||
"integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz",
|
||||
"integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-pfx": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
|
||||
"integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
|
||||
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
|
||||
"integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz",
|
||||
"integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz",
|
||||
"integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -1177,6 +1353,25 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz",
|
||||
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.3.10",
|
||||
"@peculiar/asn1-ecc": "^2.3.8",
|
||||
"@peculiar/asn1-rsa": "^2.3.8",
|
||||
"@peculiar/asn1-schema": "^2.3.8",
|
||||
"@peculiar/asn1-x509": "^2.3.8",
|
||||
"@peculiar/x509": "^1.13.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/quickjs-emscripten": {
|
||||
"version": "0.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
|
||||
@@ -1233,7 +1428,6 @@
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
@@ -1255,12 +1449,20 @@
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -1296,8 +1498,8 @@
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
|
||||
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -1308,7 +1510,6 @@
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
|
||||
"integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@@ -1332,7 +1533,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonfile": {
|
||||
@@ -1345,6 +1545,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
@@ -1356,6 +1567,12 @@
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1373,7 +1590,6 @@
|
||||
"version": "24.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -1391,21 +1607,18 @@
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -1415,7 +1628,6 @@
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
@@ -1427,7 +1639,6 @@
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
@@ -1808,6 +2019,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
|
||||
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -2162,6 +2387,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -2464,14 +2695,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
@@ -2934,6 +3178,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -3267,39 +3520,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz",
|
||||
"integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
@@ -3312,6 +3565,21 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
@@ -4235,12 +4503,103 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -5109,6 +5468,24 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -5190,6 +5567,12 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -6101,6 +6484,24 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
@@ -6173,7 +6574,6 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.30",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "ts-node src/server.ts",
|
||||
@@ -9,6 +9,7 @@
|
||||
"generate": "drizzle-kit generate",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"reset-password": "ts-node scripts/reset-password.ts",
|
||||
"postinstall": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -16,15 +17,19 @@
|
||||
"license": "ISC",
|
||||
"description": "Backend for MyTube video streaming website",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.4.6",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.22.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -36,6 +41,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
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: []
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
90
backend/src/services/authService.ts
Normal file
90
backend/src/services/authService.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "default_development_secret_do_not_use_in_production";
|
||||
const JWT_EXPIRES_IN = "24h";
|
||||
const COOKIE_NAME = "mytube_auth_token";
|
||||
|
||||
export interface UserPayload {
|
||||
role: "admin" | "visitor";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a user
|
||||
*/
|
||||
export const generateToken = (payload: UserPayload): string => {
|
||||
return jwt.sign({ ...payload, id: payload.id || uuidv4() }, JWT_SECRET, {
|
||||
expiresIn: JWT_EXPIRES_IN,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export const verifyToken = (token: string): UserPayload | null => {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set HTTP-only cookie with authentication token
|
||||
* This is more secure than storing tokens in localStorage as it's not accessible to JavaScript
|
||||
*/
|
||||
export const setAuthCookie = (res: Response, token: string, role: "admin" | "visitor"): void => {
|
||||
// Calculate expiration time (24 hours in milliseconds)
|
||||
const maxAge = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Set HTTP-only cookie (not accessible to JavaScript, preventing XSS attacks)
|
||||
// SameSite=Strict provides CSRF protection
|
||||
// Secure flag should be set in production (HTTPS only)
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const isSecure = process.env.SECURE_COOKIES === "true" || isProduction;
|
||||
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true, // Not accessible to JavaScript
|
||||
secure: isSecure, // Only sent over HTTPS in production
|
||||
sameSite: "strict", // CSRF protection
|
||||
maxAge: maxAge, // 24 hours
|
||||
path: "/", // Available for all paths
|
||||
});
|
||||
|
||||
// Also set role in a separate cookie (non-HTTP-only for frontend to read)
|
||||
res.cookie("mytube_role", role, {
|
||||
httpOnly: false, // Frontend needs to read this
|
||||
secure: isSecure,
|
||||
sameSite: "strict",
|
||||
maxAge: maxAge,
|
||||
path: "/",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear authentication cookies
|
||||
*/
|
||||
export const clearAuthCookie = (res: Response): void => {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: process.env.SECURE_COOKIES === "true" || process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
});
|
||||
res.clearCookie("mytube_role", {
|
||||
httpOnly: false,
|
||||
secure: process.env.SECURE_COOKIES === "true" || process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cookie name for authentication token
|
||||
*/
|
||||
export const getAuthCookieName = (): string => {
|
||||
return COOKIE_NAME;
|
||||
};
|
||||
@@ -8,6 +8,11 @@ import { cleanupTemporaryFiles, safeRemove } from "../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../utils/helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { ProgressTracker } from "../../utils/progressTracker";
|
||||
import {
|
||||
flagsToArgs,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} from "../../utils/ytDlpUtils";
|
||||
import * as storageService from "../storageService";
|
||||
import { Video } from "../storageService";
|
||||
import { BaseDownloader, DownloadOptions, VideoInfo } from "./BaseDownloader";
|
||||
@@ -150,33 +155,50 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
thumbnail: thumbnailUrl,
|
||||
});
|
||||
|
||||
// 3. Select the best m3u8 URL from collected URLs
|
||||
// Prefer specific quality playlists over master playlists
|
||||
// 3. Get user's yt-dlp configuration early to check for format sort
|
||||
// This helps determine m3u8 URL selection strategy and will be reused later
|
||||
const userConfig = getUserYtDlpConfig(url);
|
||||
const hasFormatSort = !!(userConfig.S || userConfig.formatSort);
|
||||
|
||||
// 4. Select the best m3u8 URL from collected URLs
|
||||
// If user specified format sort, prefer master playlists so yt-dlp can choose resolution
|
||||
// Otherwise, prefer specific quality playlists
|
||||
let m3u8Url: string | null = null;
|
||||
if (m3u8Urls.length > 0) {
|
||||
// Sort URLs: prefer specific quality playlists, avoid master playlists
|
||||
// Sort URLs based on whether user wants format sort
|
||||
const sortedUrls = m3u8Urls.sort((a, b) => {
|
||||
const aIsMaster =
|
||||
a.includes("/playlist.m3u8") || a.includes("/master/");
|
||||
const bIsMaster =
|
||||
b.includes("/playlist.m3u8") || b.includes("/master/");
|
||||
|
||||
// Prefer non-master playlists
|
||||
if (aIsMaster && !bIsMaster) return 1;
|
||||
if (!aIsMaster && bIsMaster) return -1;
|
||||
|
||||
// Among non-master playlists, prefer higher quality (480p > 240p)
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
if (hasFormatSort) {
|
||||
// When format sort is specified, prefer master playlists
|
||||
// so yt-dlp can apply format sort to choose the right resolution
|
||||
if (aIsMaster && !bIsMaster) return -1; // Master playlist first
|
||||
if (!aIsMaster && bIsMaster) return 1;
|
||||
// Among master playlists or non-master playlists, prefer higher quality
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
} else {
|
||||
// Default behavior: prefer specific quality playlists over master playlists
|
||||
if (aIsMaster && !bIsMaster) return 1;
|
||||
if (!aIsMaster && bIsMaster) return -1;
|
||||
// Among non-master playlists, prefer higher quality (480p > 240p)
|
||||
const aQuality = a.match(/(\d+p)/)?.[1] || "0p";
|
||||
const bQuality = b.match(/(\d+p)/)?.[1] || "0p";
|
||||
const aQualityNum = parseInt(aQuality) || 0;
|
||||
const bQualityNum = parseInt(bQuality) || 0;
|
||||
return bQualityNum - aQualityNum; // Higher quality first
|
||||
}
|
||||
});
|
||||
|
||||
m3u8Url = sortedUrls[0];
|
||||
logger.info(
|
||||
`Selected m3u8 URL from ${m3u8Urls.length} candidates:`,
|
||||
`Selected m3u8 URL from ${m3u8Urls.length} candidates (format sort: ${hasFormatSort}):`,
|
||||
m3u8Url
|
||||
);
|
||||
if (sortedUrls.length > 1) {
|
||||
@@ -184,7 +206,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
// 5. If m3u8 URL was not found via network, try regex extraction as fallback
|
||||
if (!m3u8Url) {
|
||||
logger.info(
|
||||
"m3u8 URL not found via network, trying regex extraction..."
|
||||
@@ -229,19 +251,26 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update the safe base filename with the actual title
|
||||
// 5. Get network configuration from user config (already loaded above)
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Get merge output format from user config or default to mp4
|
||||
const mergeOutputFormat = userConfig.mergeOutputFormat || "mp4";
|
||||
|
||||
// 6. Update the safe base filename with the actual title
|
||||
// Use the correct extension based on merge output format
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoFilename = `${newSafeBaseFilename}.mp4`;
|
||||
const newVideoFilename = `${newSafeBaseFilename}.${mergeOutputFormat}`;
|
||||
const newThumbnailFilename = `${newSafeBaseFilename}.jpg`;
|
||||
|
||||
const newVideoPath = path.join(VIDEOS_DIR, newVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, newThumbnailFilename);
|
||||
|
||||
// 6. Download the video using yt-dlp with the m3u8 URL
|
||||
// 7. Download the video using yt-dlp with the m3u8 URL
|
||||
logger.info("Downloading video from m3u8 URL using yt-dlp:", m3u8Url);
|
||||
logger.info("Downloading video to:", newVideoPath);
|
||||
logger.info("Download ID:", downloadId);
|
||||
@@ -257,19 +286,53 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// Get format sort option if user specified it
|
||||
const formatSortValue = userConfig.S || userConfig.formatSort;
|
||||
|
||||
// Default format - use bestvideo*+bestaudio/best to support highest resolution
|
||||
// This allows downloading 1080p or higher if available
|
||||
let downloadFormat = "bestvideo*+bestaudio/best";
|
||||
|
||||
// If user specified a format, use it
|
||||
if (userConfig.f || userConfig.format) {
|
||||
downloadFormat = userConfig.f || userConfig.format;
|
||||
logger.info("Using user-specified format for MissAV:", downloadFormat);
|
||||
} else if (formatSortValue) {
|
||||
// If user specified format sort but not format, use a more permissive format
|
||||
// that allows format sort to work properly with m3u8 streams
|
||||
// This ensures format sort (e.g., -S res:360) can properly filter resolutions
|
||||
downloadFormat = "bestvideo+bestaudio/best";
|
||||
logger.info(
|
||||
"Using permissive format with format sort for MissAV:",
|
||||
downloadFormat,
|
||||
"format sort:",
|
||||
formatSortValue
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare flags for yt-dlp to download m3u8 stream
|
||||
// Dynamically determine Referer based on the input URL domain
|
||||
const urlObj = new URL(url);
|
||||
const referer = `${urlObj.protocol}//${urlObj.host}/`;
|
||||
const urlObjForReferer = new URL(url);
|
||||
const referer = `${urlObjForReferer.protocol}//${urlObjForReferer.host}/`;
|
||||
logger.info("Using Referer:", referer);
|
||||
|
||||
// Prepare flags object - merge user config with required settings
|
||||
const flags: any = {
|
||||
...networkConfig, // Apply network settings (proxy, etc.)
|
||||
output: newVideoPath,
|
||||
format: "best",
|
||||
mergeOutputFormat: "mp4",
|
||||
format: downloadFormat,
|
||||
mergeOutputFormat: mergeOutputFormat,
|
||||
addHeader: [`Referer:${referer}`, `User-Agent:${userAgent}`],
|
||||
};
|
||||
|
||||
// Apply format sort if user specified it
|
||||
if (formatSortValue) {
|
||||
flags.formatSort = formatSortValue;
|
||||
logger.info("Using format sort for MissAV:", formatSortValue);
|
||||
}
|
||||
|
||||
logger.info("Final MissAV yt-dlp flags:", flags);
|
||||
|
||||
// Use ProgressTracker for centralized progress parsing
|
||||
const progressTracker = new ProgressTracker(downloadId);
|
||||
const parseProgress = (output: string, source: "stdout" | "stderr") => {
|
||||
@@ -286,20 +349,15 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
|
||||
logger.info("Starting yt-dlp process with spawn...");
|
||||
|
||||
// Convert flags object to array of args
|
||||
const args = [
|
||||
m3u8Url,
|
||||
"--output",
|
||||
newVideoPath,
|
||||
"--format",
|
||||
"best",
|
||||
"--merge-output-format",
|
||||
"mp4",
|
||||
"--add-header",
|
||||
`Referer:${referer}`,
|
||||
"--add-header",
|
||||
`User-Agent:${userAgent}`,
|
||||
];
|
||||
// Convert flags object to array of args using the utility function
|
||||
const args = [m3u8Url, ...flagsToArgs(flags)];
|
||||
|
||||
// Log the full command for debugging
|
||||
logger.info(
|
||||
"Executing yt-dlp command:",
|
||||
YT_DLP_PATH,
|
||||
args.join(" ")
|
||||
);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -357,7 +415,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 7. Download and save the thumbnail
|
||||
// 8. Download and save the thumbnail
|
||||
if (thumbnailUrl) {
|
||||
// Use base class method via temporary instance
|
||||
const downloader = new MissAVDownloader();
|
||||
@@ -367,7 +425,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Get video duration
|
||||
// 9. Get video duration
|
||||
let duration: string | undefined;
|
||||
try {
|
||||
const { getVideoDuration } = await import(
|
||||
@@ -381,7 +439,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
logger.error("Failed to extract duration from MissAV video:", e);
|
||||
}
|
||||
|
||||
// 9. Get file size
|
||||
// 10. Get file size
|
||||
let fileSize: string | undefined;
|
||||
try {
|
||||
if (fs.existsSync(newVideoPath)) {
|
||||
@@ -392,7 +450,7 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
logger.error("Failed to get file size:", e);
|
||||
}
|
||||
|
||||
// 10. Save metadata
|
||||
// 11. Save metadata
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle,
|
||||
@@ -419,19 +477,52 @@ export class MissAVDownloader extends BaseDownloader {
|
||||
return videoData;
|
||||
} catch (error: any) {
|
||||
logger.error("Error in downloadMissAVVideo:", error);
|
||||
// Cleanup
|
||||
const newSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const newVideoPath = path.join(VIDEOS_DIR, `${newSafeBaseFilename}.mp4`);
|
||||
const newThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${newSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(newVideoPath)) await safeRemove(newVideoPath);
|
||||
if (fs.existsSync(newThumbnailPath)) await safeRemove(newThumbnailPath);
|
||||
// Cleanup - try to get the correct extension from config, fallback to mp4
|
||||
try {
|
||||
const cleanupConfig = getUserYtDlpConfig(url);
|
||||
const cleanupFormat = cleanupConfig.mergeOutputFormat || "mp4";
|
||||
const cleanupSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const cleanupVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.${cleanupFormat}`
|
||||
);
|
||||
const cleanupThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${cleanupSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPath)) await safeRemove(cleanupVideoPath);
|
||||
if (fs.existsSync(cleanupThumbnailPath))
|
||||
await safeRemove(cleanupThumbnailPath);
|
||||
// Also try mp4 in case the file was created with default extension
|
||||
const cleanupVideoPathMp4 = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.mp4`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPathMp4))
|
||||
await safeRemove(cleanupVideoPathMp4);
|
||||
} catch (cleanupError) {
|
||||
// If cleanup fails, try with default mp4 extension
|
||||
const cleanupSafeBaseFilename = formatVideoFilename(
|
||||
videoTitle,
|
||||
videoAuthor,
|
||||
videoDate
|
||||
);
|
||||
const cleanupVideoPath = path.join(
|
||||
VIDEOS_DIR,
|
||||
`${cleanupSafeBaseFilename}.mp4`
|
||||
);
|
||||
const cleanupThumbnailPath = path.join(
|
||||
IMAGES_DIR,
|
||||
`${cleanupSafeBaseFilename}.jpg`
|
||||
);
|
||||
if (fs.existsSync(cleanupVideoPath)) await safeRemove(cleanupVideoPath);
|
||||
if (fs.existsSync(cleanupThumbnailPath))
|
||||
await safeRemove(cleanupThumbnailPath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +32,8 @@ class YtDlpDownloaderHelper extends BaseDownloader {
|
||||
*/
|
||||
export async function processSubtitles(
|
||||
baseFilename: string,
|
||||
downloadId?: string
|
||||
downloadId?: string,
|
||||
moveSubtitlesToVideoFolder: boolean = false
|
||||
): Promise<Array<{ language: string; filename: string; path: string }>> {
|
||||
const subtitles: Array<{ language: string; filename: string; path: string }> =
|
||||
[];
|
||||
@@ -64,11 +65,20 @@ export async function processSubtitles(
|
||||
);
|
||||
const language = match ? match[1] : "unknown";
|
||||
|
||||
// Move subtitle to subtitles directory
|
||||
// Move subtitle to subtitles directory or keep in video directory if requested
|
||||
const sourceSubPath = path.join(VIDEOS_DIR, subtitleFile);
|
||||
const destSubFilename = `${baseFilename}.${language}.vtt`;
|
||||
const destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
|
||||
let destSubPath: string;
|
||||
let webPath: string;
|
||||
|
||||
if (moveSubtitlesToVideoFolder) {
|
||||
destSubPath = path.join(VIDEOS_DIR, destSubFilename);
|
||||
webPath = `/videos/${destSubFilename}`;
|
||||
} else {
|
||||
destSubPath = path.join(SUBTITLES_DIR, destSubFilename);
|
||||
webPath = `/subtitles/${destSubFilename}`;
|
||||
}
|
||||
|
||||
// Read VTT file and fix alignment for centering
|
||||
let vttContent = fs.readFileSync(sourceSubPath, "utf-8");
|
||||
// Replace align:start with align:middle for centered subtitles
|
||||
@@ -79,8 +89,14 @@ export async function processSubtitles(
|
||||
// Write cleaned VTT to destination
|
||||
fs.writeFileSync(destSubPath, vttContent, "utf-8");
|
||||
|
||||
// Remove original file
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
// Remove original file if we moved it (if dest is different from source)
|
||||
// If moveSubtitlesToVideoFolder is true, destSubPath might be same as sourceSubPath
|
||||
// but with different name (e.g. video_uuid.en.vtt vs video_uuid.vtt)
|
||||
// Actually source is usually video_uuid.en.vtt (from yt-dlp) and dest is video_uuid.en.vtt
|
||||
// So if names are same and dir is same, we're just overwriting in place, which is fine
|
||||
if (sourceSubPath !== destSubPath) {
|
||||
fs.unlinkSync(sourceSubPath);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Processed and moved subtitle ${subtitleFile} to ${destSubPath}`
|
||||
@@ -89,7 +105,7 @@ export async function processSubtitles(
|
||||
subtitles.push({
|
||||
language,
|
||||
filename: destSubFilename,
|
||||
path: `/subtitles/${destSubFilename}`,
|
||||
path: webPath,
|
||||
});
|
||||
}
|
||||
} catch (subtitleError) {
|
||||
|
||||
@@ -2,16 +2,17 @@ import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { IMAGES_DIR, VIDEOS_DIR } from "../../../config/paths";
|
||||
import {
|
||||
cleanupSubtitleFiles,
|
||||
cleanupVideoArtifacts,
|
||||
cleanupSubtitleFiles,
|
||||
cleanupVideoArtifacts,
|
||||
} from "../../../utils/downloadUtils";
|
||||
import { formatVideoFilename } from "../../../utils/helpers";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { ProgressTracker } from "../../../utils/progressTracker";
|
||||
import {
|
||||
executeYtDlpJson,
|
||||
executeYtDlpSpawn,
|
||||
getUserYtDlpConfig,
|
||||
executeYtDlpJson,
|
||||
executeYtDlpSpawn,
|
||||
getNetworkConfigFromUserConfig,
|
||||
getUserYtDlpConfig,
|
||||
} from "../../../utils/ytDlpUtils";
|
||||
import * as storageService from "../../storageService";
|
||||
import { Video } from "../../storageService";
|
||||
@@ -86,8 +87,13 @@ export async function downloadVideo(
|
||||
try {
|
||||
const PROVIDER_SCRIPT = getProviderScript();
|
||||
|
||||
// Get user's yt-dlp configuration for network options (including proxy)
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
|
||||
|
||||
// Get video info first
|
||||
const info = await executeYtDlpJson(videoUrl, {
|
||||
...networkConfig,
|
||||
noWarnings: true,
|
||||
preferFreeFormats: true,
|
||||
...(PROVIDER_SCRIPT
|
||||
@@ -130,8 +136,16 @@ export async function downloadVideo(
|
||||
finalThumbnailFilename = newThumbnailFilename;
|
||||
|
||||
// Update paths
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
const moveSubtitlesToVideoFolder =
|
||||
settings.moveSubtitlesToVideoFolder || false;
|
||||
|
||||
const newVideoPath = path.join(VIDEOS_DIR, finalVideoFilename);
|
||||
const newThumbnailPath = path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
const newThumbnailPath = moveThumbnailsToVideoFolder
|
||||
? path.join(VIDEOS_DIR, finalThumbnailFilename)
|
||||
: path.join(IMAGES_DIR, finalThumbnailFilename);
|
||||
|
||||
logger.info("Preparing video download path:", newVideoPath);
|
||||
|
||||
@@ -142,16 +156,32 @@ export async function downloadVideo(
|
||||
});
|
||||
}
|
||||
|
||||
// Get user's yt-dlp configuration
|
||||
const userConfig = getUserYtDlpConfig(videoUrl);
|
||||
// Get user's yt-dlp configuration (reuse from above if available, otherwise fetch again)
|
||||
// Note: userConfig was already fetched above, but we need to ensure it's still valid
|
||||
const downloadUserConfig = userConfig || getUserYtDlpConfig(videoUrl);
|
||||
|
||||
// Log proxy configuration for debugging
|
||||
if (downloadUserConfig.proxy) {
|
||||
logger.info("Using proxy for download:", downloadUserConfig.proxy);
|
||||
}
|
||||
|
||||
// Prepare download flags
|
||||
const { flags, mergeOutputFormat } = prepareDownloadFlags(
|
||||
videoUrl,
|
||||
newVideoPath,
|
||||
userConfig
|
||||
downloadUserConfig
|
||||
);
|
||||
|
||||
// Log final flags to verify proxy is included
|
||||
if (flags.proxy) {
|
||||
logger.info("Proxy included in download flags:", flags.proxy);
|
||||
} else {
|
||||
logger.warn(
|
||||
"Proxy not found in download flags. User config proxy:",
|
||||
downloadUserConfig.proxy
|
||||
);
|
||||
}
|
||||
|
||||
// Update the video path to use the correct extension based on merge format
|
||||
const videoExtension = mergeOutputFormat;
|
||||
const newVideoPathWithFormat = newVideoPath.replace(
|
||||
@@ -181,7 +211,13 @@ export async function downloadVideo(
|
||||
// Clean up partial files
|
||||
logger.info("Cleaning up partial files...");
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename);
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename, IMAGES_DIR);
|
||||
|
||||
// Use fresh cleanup based on settings
|
||||
const currentSettings = storageService.getSettings();
|
||||
if (!currentSettings.moveThumbnailsToVideoFolder) {
|
||||
await cleanupVideoArtifacts(newSafeBaseFilename, IMAGES_DIR);
|
||||
}
|
||||
|
||||
if (fs.existsSync(newThumbnailPath)) {
|
||||
await fs.remove(newThumbnailPath);
|
||||
}
|
||||
@@ -271,13 +307,21 @@ export async function downloadVideo(
|
||||
}
|
||||
|
||||
// Process subtitle files
|
||||
subtitles = await processSubtitles(newSafeBaseFilename, downloadId);
|
||||
subtitles = await processSubtitles(
|
||||
newSafeBaseFilename,
|
||||
downloadId,
|
||||
moveSubtitlesToVideoFolder
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error in download process:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Create metadata for the video
|
||||
const settings = storageService.getSettings();
|
||||
const moveThumbnailsToVideoFolder =
|
||||
settings.moveThumbnailsToVideoFolder || false;
|
||||
|
||||
const videoData: Video = {
|
||||
id: timestamp.toString(),
|
||||
title: videoTitle || "Video",
|
||||
@@ -290,7 +334,11 @@ export async function downloadVideo(
|
||||
thumbnailFilename: thumbnailSaved ? finalThumbnailFilename : undefined,
|
||||
thumbnailUrl: thumbnailUrl || undefined,
|
||||
videoPath: `/videos/${finalVideoFilename}`,
|
||||
thumbnailPath: thumbnailSaved ? `/images/${finalThumbnailFilename}` : null,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: null,
|
||||
subtitles: subtitles.length > 0 ? subtitles : undefined,
|
||||
duration: undefined, // Will be populated below
|
||||
channelUrl: channelUrl || undefined,
|
||||
@@ -345,7 +393,9 @@ export async function downloadVideo(
|
||||
? finalThumbnailFilename
|
||||
: existingVideo.thumbnailFilename,
|
||||
thumbnailPath: thumbnailSaved
|
||||
? `/images/${finalThumbnailFilename}`
|
||||
? moveThumbnailsToVideoFolder
|
||||
? `/videos/${finalThumbnailFilename}`
|
||||
: `/images/${finalThumbnailFilename}`
|
||||
: existingVideo.thumbnailPath,
|
||||
duration: videoData.duration,
|
||||
fileSize: videoData.fileSize,
|
||||
|
||||
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("..")) {
|
||||
|
||||
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.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.30",
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.13.2",
|
||||
@@ -1918,6 +1919,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
|
||||
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.7.21",
|
||||
"version": "1.7.30",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -16,6 +16,7 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@@ -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';
|
||||
@@ -45,14 +47,40 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
onTagToggle
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { visitorMode } = useVisitorMode();
|
||||
const { logout, userRole } = useAuth();
|
||||
const isVisitor = userRole === 'visitor';
|
||||
const navigate = useNavigate();
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
// Check if login is enabled
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
retry: 1,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const loginEnabled = settingsData?.loginEnabled || false;
|
||||
|
||||
const handleLogout = () => {
|
||||
onClose();
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapse in={open} sx={{ width: '100%' }}>
|
||||
<Box sx={{ maxHeight: '80vh', overflowY: 'auto' }}>
|
||||
<Stack spacing={2} sx={{ py: 2 }}>
|
||||
{/* Row 1: Search Input */}
|
||||
{!visitorMode && (
|
||||
{!isVisitor && (
|
||||
<Box>
|
||||
<SearchInput
|
||||
videoUrl={videoUrl}
|
||||
@@ -91,6 +119,22 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Logout Button */}
|
||||
{loginEnabled && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
onClick={handleLogout}
|
||||
startIcon={<Logout />}
|
||||
>
|
||||
{t('logout')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile Navigation Items */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Collections
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5551/api';
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
timeout: 30000, // 30 seconds default timeout
|
||||
withCredentials: true, // Required for HTTP-only cookies
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -19,10 +20,12 @@ const apiClient: AxiosInstance = axios.create({
|
||||
|
||||
/**
|
||||
* Request interceptor - can be used for adding auth tokens, logging, etc.
|
||||
* Note: Authentication is now handled via HTTP-only cookies, so no Authorization header is needed
|
||||
*/
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add any request modifications here (e.g., auth tokens)
|
||||
// Cookies are automatically sent with requests when withCredentials: true
|
||||
// No need to manually add Authorization header
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
@@ -187,7 +190,7 @@ export { apiClient };
|
||||
/**
|
||||
* Export API_URL for cases where it's needed directly
|
||||
*/
|
||||
export { API_URL };
|
||||
export { API_URL };
|
||||
|
||||
export default api;
|
||||
|
||||
|
||||
@@ -81,6 +81,106 @@ export const formatDate = (dateString?: string) => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format relative time from download timestamp to current time
|
||||
* 0 - 1 hour: "Just now"
|
||||
* 1 hour - 5 hours: "X hours ago"
|
||||
* 5 hours - 24 hours: "Today"
|
||||
* 1 day - 7 days: "This week"
|
||||
* 1 week - 4 weeks: "X weeks ago"
|
||||
* > 4 weeks: show actual date
|
||||
*/
|
||||
export const formatRelativeDownloadTime = (
|
||||
downloadTimestamp?: string,
|
||||
originalDate?: string,
|
||||
t?: (key: string, replacements?: Record<string, string | number>) => string
|
||||
): string => {
|
||||
const getTranslation = (
|
||||
key: string,
|
||||
replacements?: Record<string, string | number>
|
||||
): string => {
|
||||
if (t) {
|
||||
return t(key as any, replacements);
|
||||
}
|
||||
// Fallback to English if no translation function provided
|
||||
const fallbacks: Record<string, string> = {
|
||||
justNow: "Just now",
|
||||
hoursAgo: "{hours} hours ago",
|
||||
today: "Today",
|
||||
thisWeek: "This week",
|
||||
weeksAgo: "{weeks} weeks ago",
|
||||
unknownDate: "Unknown date",
|
||||
};
|
||||
let text = fallbacks[key] || key;
|
||||
if (replacements) {
|
||||
Object.entries(replacements).forEach(([placeholder, value]) => {
|
||||
text = text.replace(`{${placeholder}}`, String(value));
|
||||
});
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
if (!downloadTimestamp) {
|
||||
// Fallback to original date format if no download timestamp
|
||||
return originalDate
|
||||
? formatDate(originalDate)
|
||||
: getTranslation("unknownDate");
|
||||
}
|
||||
|
||||
const downloadDate = new Date(downloadTimestamp);
|
||||
const now = new Date();
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(downloadDate.getTime())) {
|
||||
return originalDate
|
||||
? formatDate(originalDate)
|
||||
: getTranslation("unknownDate");
|
||||
}
|
||||
|
||||
const diffMs = now.getTime() - downloadDate.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
const diffWeeks = diffDays / 7;
|
||||
|
||||
// 0 - 1 hour: "Just now"
|
||||
if (diffHours < 1) {
|
||||
return getTranslation("justNow");
|
||||
}
|
||||
|
||||
// 1 hour - 5 hours: "X hours ago"
|
||||
if (diffHours >= 1 && diffHours < 5) {
|
||||
const hours = Math.floor(diffHours);
|
||||
return getTranslation("hoursAgo", { hours });
|
||||
}
|
||||
|
||||
// 5 hours - 24 hours: "Today"
|
||||
if (diffHours >= 5 && diffHours < 24) {
|
||||
return getTranslation("today");
|
||||
}
|
||||
|
||||
// 1 day - 7 days: "This week"
|
||||
if (diffDays >= 1 && diffDays < 7) {
|
||||
return getTranslation("thisWeek");
|
||||
}
|
||||
|
||||
// 1 week - 4 weeks: "X周前" / "X weeks ago"
|
||||
if (diffWeeks >= 1 && diffWeeks < 4) {
|
||||
const weeks = Math.floor(diffWeeks);
|
||||
return getTranslation("weeksAgo", { weeks });
|
||||
}
|
||||
|
||||
// > 4 weeks: show actual date
|
||||
if (originalDate) {
|
||||
return formatDate(originalDate);
|
||||
}
|
||||
// Format download date as YYYY-MM-DD if no original date
|
||||
// Use UTC methods to ensure timezone independence
|
||||
const year = downloadDate.getUTCFullYear();
|
||||
const month = String(downloadDate.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(downloadDate.getUTCDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate timestamp string in format YYYY-MM-DD-HH-MM-SS
|
||||
* Matches the backend generateTimestamp() function format
|
||||
@@ -101,14 +201,17 @@ export const generateTimestamp = (): string => {
|
||||
* If path is already a full URL (starts with http:// or https://), return it as is
|
||||
* Otherwise, prepend BACKEND_URL
|
||||
*/
|
||||
export const getFileUrl = (path: string | null | undefined, backendUrl: string): string | undefined => {
|
||||
export const getFileUrl = (
|
||||
path: string | null | undefined,
|
||||
backendUrl: string
|
||||
): string | undefined => {
|
||||
if (!path) return undefined;
|
||||
|
||||
|
||||
// Check if path is already a full URL
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
// Otherwise, prepend backend URL
|
||||
return `${backendUrl}${path}`;
|
||||
};
|
||||
|
||||
@@ -52,6 +52,12 @@ export const ar = {
|
||||
videoColumns: "أعمدة الفيديو (الصفحة الرئيسية)",
|
||||
columnsCount: "{count} أعمدة",
|
||||
enableLogin: "تفعيل حماية تسجيل الدخول",
|
||||
allowPasswordLogin: "السماح بتسجيل الدخول بكلمة المرور",
|
||||
allowPasswordLoginHelper:
|
||||
"عند التعطيل، لن يكون تسجيل الدخول بكلمة المرور متاحًا. يجب أن يكون لديك مفتاح وصول واحد على الأقل لتعطيل تسجيل الدخول بكلمة المرور.",
|
||||
allowResetPassword: "السماح بإعادة تعيين كلمة المرور",
|
||||
allowResetPasswordHelper:
|
||||
"عند التعطيل، لن يتم عرض زر إعادة تعيين كلمة المرور في صفحة تسجيل الدخول وستتم حظر واجهة برمجة تطبيقات إعادة تعيين كلمة المرور.",
|
||||
password: "كلمة المرور",
|
||||
enterPassword: "أدخل كلمة المرور",
|
||||
togglePasswordVisibility: "تبديل رؤية كلمة المرور",
|
||||
@@ -127,10 +133,7 @@ export const ar = {
|
||||
itemsPerPage: "عناصر لكل صفحة",
|
||||
itemsPerPageHelper: "عدد مقاطع الفيديو المعروضة في كل صفحة (الافتراضي: 12)",
|
||||
showYoutubeSearch: "عرض نتائج بحث YouTube",
|
||||
visitorMode: "وضع الزائر (للقراءة فقط)",
|
||||
visitorModeReadOnly: "وضع الزائر: للقراءة فقط",
|
||||
visitorModeDescription: "وضع القراءة فقط. لن تكون مقاطع الفيديو المخفية مرئية للزوار.",
|
||||
visitorModePasswordPrompt: "يرجى إدخال كلمة مرور الموقع لتغيير إعدادات وضع الزائر.",
|
||||
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
|
||||
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
|
||||
|
||||
@@ -159,11 +162,13 @@ export const ar = {
|
||||
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
|
||||
token: "الرمز المميز (Token)",
|
||||
publicUrl: "عنوان URL العام",
|
||||
publicUrlHelper: "النطاق العام للوصول إلى الملفات (مثال: https://your-cloudflare-tunnel-domain.com). إذا تم تعيينه، سيتم استخدامه بدلاً من عنوان API للوصول إلى الملفات.",
|
||||
publicUrlHelper:
|
||||
"النطاق العام للوصول إلى الملفات (مثال: https://your-cloudflare-tunnel-domain.com). إذا تم تعيينه، سيتم استخدامه بدلاً من عنوان API للوصول إلى الملفات.",
|
||||
uploadPath: "مسار التحميل",
|
||||
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
|
||||
scanPaths: "مسارات المسح",
|
||||
scanPathsHelper: "مسار واحد في كل سطر. سيتم مسح مقاطع الفيديو من هذه المسارات. إذا كانت فارغة، سيتم استخدام مسار التحميل. مثال:\n/a/أفلام\n/b/وثائقيات",
|
||||
scanPathsHelper:
|
||||
"مسار واحد في كل سطر. سيتم مسح مقاطع الفيديو من هذه المسارات. إذا كانت فارغة، سيتم استخدام مسار التحميل. مثال:\n/a/أفلام\n/b/وثائقيات",
|
||||
cloudDriveNote:
|
||||
"بعد تفعيل هذه الميزة، سيتم تحميل مقاطع الفيديو التي تم تنزيلها حديثًا تلقائيًا إلى التخزين السحابي وسيتم حذف الملفات المحلية. سيتم تشغيل مقاطع الفيديو من التخزين السحابي عبر الوكيل.",
|
||||
cloudScanAdded: "تمت الإضافة من السحابة",
|
||||
@@ -171,7 +176,8 @@ export const ar = {
|
||||
testConnection: "اختبار الاتصال",
|
||||
sync: "مزامنة",
|
||||
syncToCloud: "مزامنة ثنائية الاتجاه",
|
||||
syncWarning: "ستقوم هذه العملية برفع مقاطع الفيديو المحلية إلى السحابة ومسح التخزين السحابي بحثًا عن ملفات جديدة. سيتم حذف الملفات المحلية بعد الرفع.",
|
||||
syncWarning:
|
||||
"ستقوم هذه العملية برفع مقاطع الفيديو المحلية إلى السحابة ومسح التخزين السحابي بحثًا عن ملفات جديدة. سيتم حذف الملفات المحلية بعد الرفع.",
|
||||
syncing: "جاري المزامنة...",
|
||||
syncCompleted: "اكتملت المزامنة",
|
||||
syncFailed: "فشلت المزامنة",
|
||||
@@ -188,9 +194,11 @@ export const ar = {
|
||||
uploadingVideo: "جاري الرفع: {title}",
|
||||
clearThumbnailCache: "مسح ذاكرة التخزين المؤقت للصور المصغرة",
|
||||
clearing: "جاري المسح...",
|
||||
clearThumbnailCacheSuccess: "تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
|
||||
clearThumbnailCacheSuccess:
|
||||
"تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
|
||||
clearThumbnailCacheError: "فشل مسح ذاكرة التخزين المؤقت للصور المصغرة",
|
||||
clearThumbnailCacheConfirmMessage: "سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
|
||||
clearThumbnailCacheConfirmMessage:
|
||||
"سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
|
||||
|
||||
// Manage
|
||||
manageContent: "إدارة المحتوى",
|
||||
@@ -280,7 +288,8 @@ export const ar = {
|
||||
openInExternalPlayer: "فتح في مشغل خارجي",
|
||||
playWith: "تشغيل بواسطة...",
|
||||
deleteAllFilteredVideos: "حذف جميع الفيديوهات المصفاة",
|
||||
confirmDeleteFilteredVideos: "هل أنت متأكد أنك تريد حذف {count} فيديو مصفى بواسطة العلامات المحددة؟",
|
||||
confirmDeleteFilteredVideos:
|
||||
"هل أنت متأكد أنك تريد حذف {count} فيديو مصفى بواسطة العلامات المحددة؟",
|
||||
deleteFilteredVideosSuccess: "تم حذف {count} فيديو بنجاح.",
|
||||
deletingVideos: "جاري حذف الفيديوهات...",
|
||||
|
||||
@@ -302,10 +311,35 @@ export const ar = {
|
||||
resetPasswordConfirm: "إعادة التعيين",
|
||||
resetPasswordSuccess:
|
||||
"تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
|
||||
resetPasswordDisabledInfo:
|
||||
"تم تعطيل إعادة تعيين كلمة المرور. لإعادة تعيين كلمة المرور، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nسيؤدي هذا إلى إنشاء كلمة مرور عشوائية جديدة وتمكين تسجيل الدخول بكلمة المرور.",
|
||||
resetPasswordScriptGuide:
|
||||
"لإعادة تعيين كلمة المرور يدوياً، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nإذا لم يتم توفير كلمة مرور، سيتم إنشاء كلمة مرور عشوائية مكونة من 8 أحرف.",
|
||||
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
|
||||
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
|
||||
// Passkeys
|
||||
createPasskey: "إنشاء مفتاح مرور",
|
||||
creatingPasskey: "جاري الإنشاء...",
|
||||
passkeyCreated: "تم إنشاء مفتاح المرور بنجاح",
|
||||
passkeyCreationFailed: "فشل إنشاء مفتاح المرور. يرجى المحاولة مرة أخرى.",
|
||||
removePasskeys: "إزالة جميع مفاتيح المرور",
|
||||
removePasskeysTitle: "إزالة جميع مفاتيح المرور",
|
||||
removePasskeysMessage:
|
||||
"هل أنت متأكد من أنك تريد إزالة جميع مفاتيح المرور؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
passkeysRemoved: "تم إزالة جميع مفاتيح المرور",
|
||||
passkeysRemoveFailed: "فشل إزالة مفاتيح المرور. يرجى المحاولة مرة أخرى.",
|
||||
loginWithPasskey: "تسجيل الدخول بمفتاح المرور",
|
||||
authenticating: "جاري المصادقة...",
|
||||
passkeyLoginFailed: "فشلت مصادقة مفتاح المرور. يرجى المحاولة مرة أخرى.",
|
||||
passkeyErrorPermissionDenied:
|
||||
"لا يُسمح بالطلب من قبل وكيل المستخدم أو المنصة في السياق الحالي، ربما لأن المستخدم رفض الإذن.",
|
||||
passkeyErrorAlreadyRegistered: "تم تسجيل المصادق مسبقاً.",
|
||||
linkCopied: "تم نسخ الرابط إلى الحافظة",
|
||||
copyFailed: "فشل نسخ الرابط",
|
||||
passkeyRequiresHttps:
|
||||
"يتطلب WebAuthn استخدام HTTPS أو localhost. يرجى الدخول إلى التطبيق عبر HTTPS أو استخدام localhost بدلاً من عنوان IP.",
|
||||
passkeyWebAuthnNotSupported:
|
||||
"WebAuthn غير مدعوم في هذا المتصفح. يرجى استخدام متصفح حديث يدعم WebAuthn.",
|
||||
|
||||
// Collection Page
|
||||
loadingCollection: "جاري تحميل المجموعة...",
|
||||
@@ -366,6 +400,11 @@ export const ar = {
|
||||
unknownDate: "تاريخ غير معروف",
|
||||
part: "جزء",
|
||||
collection: "مجموعة",
|
||||
justNow: "الآن",
|
||||
hoursAgo: "منذ {hours} ساعة",
|
||||
today: "اليوم",
|
||||
thisWeek: "هذا الأسبوع",
|
||||
weeksAgo: "منذ {weeks} أسبوع",
|
||||
|
||||
// Upload Modal
|
||||
selectVideoFile: "اختر ملف فيديو",
|
||||
@@ -382,7 +421,8 @@ export const ar = {
|
||||
authorOrPlaylist: "المؤلف / قائمة التشغيل",
|
||||
playlistDetected: "تم اكتشاف قائمة تشغيل",
|
||||
playlistHasVideos: "تحتوي قائمة التشغيل هذه على {count} فيديوهات.",
|
||||
downloadPlaylistAndCreateCollection: "هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
|
||||
downloadPlaylistAndCreateCollection:
|
||||
"هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
|
||||
collectionHasVideos: "تحتوي هذه المجموعة من Bilibili على {count} فيديوهات.",
|
||||
seriesHasVideos: "تحتوي هذه السلسلة من Bilibili على {count} فيديوهات.",
|
||||
videoHasParts: "يحتوي هذا الفيديو من Bilibili على {count} أجزاء.",
|
||||
@@ -458,11 +498,13 @@ export const ar = {
|
||||
confirmCancelTask: "هل أنت متأكد أنك تريد إلغاء مهمة التنزيل لـ {author}؟",
|
||||
taskCancelled: "تم إلغاء المهمة بنجاح",
|
||||
deleteTask: "حذف المهمة",
|
||||
confirmDeleteTask: "هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
confirmDeleteTask:
|
||||
"هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
taskDeleted: "تم حذف المهمة بنجاح",
|
||||
clearFinishedTasks: "مسح المهام المنتهية",
|
||||
tasksCleared: "تم مسح المهام المنتهية بنجاح",
|
||||
confirmClearFinishedTasks: "هل أنت متأكد أنك تريد مسح جميع المهام المنتهية (المكتملة، الملغاة)؟ سيؤدي هذا إلى إزالتها من القائمة ولكن لن يحذف أي ملفات تم تنزيلها.",
|
||||
confirmClearFinishedTasks:
|
||||
"هل أنت متأكد أنك تريد مسح جميع المهام المنتهية (المكتملة، الملغاة)؟ سيؤدي هذا إلى إزالتها من القائمة ولكن لن يحذف أي ملفات تم تنزيلها.",
|
||||
clear: "مسح",
|
||||
// Instruction Page
|
||||
instructionSection1Title: "1. التنزيل وإدارة المهام",
|
||||
@@ -586,7 +628,8 @@ export const ar = {
|
||||
lastBackupDate: "تاريخ آخر نسخة احتياطية",
|
||||
noBackupAvailable: "لا توجد نسخة احتياطية متاحة",
|
||||
deleteAuthor: "حذف المؤلف",
|
||||
deleteAuthorConfirmation: "هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
|
||||
deleteAuthorConfirmation:
|
||||
"هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
|
||||
authorDeletedSuccessfully: "تم حذف المؤلف بنجاح",
|
||||
failedToDeleteAuthor: "فشل حذف المؤلف",
|
||||
|
||||
@@ -594,7 +637,8 @@ export const ar = {
|
||||
cloudflaredTunnel: "نفق Cloudflare",
|
||||
enableCloudflaredTunnel: "تمكين نفق Cloudflare",
|
||||
cloudflaredToken: "رمز النفق (اختياري)",
|
||||
cloudflaredTokenHelper: "الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
|
||||
cloudflaredTokenHelper:
|
||||
"الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
|
||||
waitingForUrl: "في انتظار عنوان النفق السريع URL...",
|
||||
running: "يعمل",
|
||||
stopped: "متوقف",
|
||||
@@ -602,8 +646,10 @@ export const ar = {
|
||||
accountTag: "علامة الحساب",
|
||||
copied: "تم النسخ!",
|
||||
clickToCopy: "انقر للنسخ",
|
||||
quickTunnelWarning: "تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
|
||||
managedInDashboard: "تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
|
||||
quickTunnelWarning:
|
||||
"تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
|
||||
managedInDashboard:
|
||||
"تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
|
||||
failedToDownloadVideo: "فشل تنزيل الفيديو. يرجى المحاولة مرة أخرى.",
|
||||
failedToDownload: "فشل التنزيل. يرجى المحاولة مرة أخرى.",
|
||||
playlistDownloadStarted: "بدأ تنزيل قائمة التشغيل",
|
||||
@@ -612,24 +658,39 @@ export const ar = {
|
||||
copyUrl: "نسخ الرابط",
|
||||
new: "جديد",
|
||||
// Task Hooks
|
||||
taskHooks: 'خطافات المهام',
|
||||
taskHooksDescription: 'نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.',
|
||||
hookTaskBeforeStart: 'قبل بدء المهمة',
|
||||
hookTaskBeforeStartHelper: 'ينفذ قبل بدء التنزيل.',
|
||||
hookTaskSuccess: 'نجاح المهمة',
|
||||
hookTaskSuccessHelper: 'ينفذ بعد التنزيل الناجح، قبل الرفع السحابي/الحذف (ينتظر الاكتمال).',
|
||||
hookTaskFail: 'فشل المهمة',
|
||||
hookTaskFailHelper: 'ينفذ عند فشل المهمة.',
|
||||
hookTaskCancel: 'إلغاء المهمة',
|
||||
hookTaskCancelHelper: 'ينفذ عند إلغاء المهمة يدوياً.',
|
||||
found: 'موجود',
|
||||
notFound: 'غير معين',
|
||||
deleteHook: 'حذف سكريبت الخطاف',
|
||||
confirmDeleteHook: 'هل أنت متأكد أنك تريد حذف سكريبت الخطاف هذا؟',
|
||||
uploadHook: 'رفع .sh',
|
||||
taskHooks: "خطافات المهام",
|
||||
taskHooksDescription:
|
||||
"نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
|
||||
taskHooksWarning: "تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.",
|
||||
enterPasswordToUploadHook: "الرجاء إدخال كلمة المرور لتحميل نص Hook هذا.",
|
||||
riskCommandDetected: "تم اكتشاف أمر خطر: {command}. تم رفض التحميل.",
|
||||
hookTaskBeforeStart: "قبل بدء المهمة",
|
||||
hookTaskBeforeStartHelper: "ينفذ قبل بدء التنزيل.",
|
||||
hookTaskSuccess: "نجاح المهمة",
|
||||
hookTaskSuccessHelper:
|
||||
"ينفذ بعد التنزيل الناجح، قبل الرفع السحابي/الحذف (ينتظر الاكتمال).",
|
||||
hookTaskFail: "فشل المهمة",
|
||||
hookTaskFailHelper: "ينفذ عند فشل المهمة.",
|
||||
hookTaskCancel: "إلغاء المهمة",
|
||||
hookTaskCancelHelper: "ينفذ عند إلغاء المهمة يدوياً.",
|
||||
found: "موجود",
|
||||
notFound: "غير معين",
|
||||
deleteHook: "حذف سكريبت الخطاف",
|
||||
confirmDeleteHook: "هل أنت متأكد أنك تريد حذف سكريبت الخطاف هذا؟",
|
||||
uploadHook: "رفع .sh",
|
||||
|
||||
disclaimerTitle: "إخلاء المسؤولية",
|
||||
disclaimerText: "1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
|
||||
|
||||
disclaimerText:
|
||||
"1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
|
||||
// Visitor Mode
|
||||
admin: "مشرف",
|
||||
visitorSignIn: "تسجيل دخول الزائر",
|
||||
visitorUser: "المستخدم الزائر",
|
||||
enableVisitorUser: "تفعيل المستخدم الزائر",
|
||||
visitorUserHelper:
|
||||
"قم تمكين حساب زائر منفصل مع وصول للقراءة فقط. يمكن للزوار عرض المحتوى ولكن لا يمكنهم إجراء تغييرات.",
|
||||
visitorPassword: "كلمة مرور الزائر",
|
||||
visitorPasswordHelper: "تعيين كلمة المرور لحساب الزائر.",
|
||||
visitorPasswordSetHelper:
|
||||
"تم تعيين كلمة المرور. اتركها فارغة للاحتفاظ بها.",
|
||||
};
|
||||
|
||||
@@ -45,11 +45,18 @@ export const de = {
|
||||
websiteName: "Website-Name",
|
||||
websiteNameHelper: "{current}/{max} Zeichen (Standard: {default})",
|
||||
infiniteScroll: "Unendliches Scrollen",
|
||||
infiniteScrollDisabled: "Deaktiviert, wenn unendliches Scrollen aktiviert ist",
|
||||
infiniteScrollDisabled:
|
||||
"Deaktiviert, wenn unendliches Scrollen aktiviert ist",
|
||||
maxVideoColumns: "Maximale Videospalten (Startseite)",
|
||||
videoColumns: "Videospalten (Startseite)",
|
||||
columnsCount: "{count} Spalten",
|
||||
enableLogin: "Anmeldeschutz aktivieren",
|
||||
allowPasswordLogin: "Passwort-Anmeldung zulassen",
|
||||
allowPasswordLoginHelper:
|
||||
"Wenn deaktiviert, ist die Passwort-Anmeldung nicht verfügbar. Sie müssen mindestens einen Passkey haben, um die Passwort-Anmeldung zu deaktivieren.",
|
||||
allowResetPassword: "Passwort zurücksetzen zulassen",
|
||||
allowResetPasswordHelper:
|
||||
"Wenn deaktiviert, wird die Schaltfläche zum Zurücksetzen des Passworts auf der Anmeldeseite nicht angezeigt und die API zum Zurücksetzen des Passworts wird blockiert.",
|
||||
password: "Passwort",
|
||||
enterPassword: "Passwort eingeben",
|
||||
togglePasswordVisibility: "Passwort sichtbar machen",
|
||||
@@ -92,7 +99,6 @@ export const de = {
|
||||
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.",
|
||||
migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
|
||||
migrationFailed: "Migration fehlgeschlagen",
|
||||
|
||||
migrationWarnings: "WARNUNGEN",
|
||||
migrationErrors: "FEHLER",
|
||||
itemsMigrated: "Elemente migriert",
|
||||
@@ -123,10 +129,7 @@ export const de = {
|
||||
itemsPerPage: "Elemente pro Seite",
|
||||
itemsPerPageHelper: "Anzahl der Videos pro Seite (Standard: 12)",
|
||||
showYoutubeSearch: "YouTube-Suchergebnisse anzeigen",
|
||||
visitorMode: "Besuchermodus (Nur-Lesen)",
|
||||
visitorModeReadOnly: "Besuchermodus: Nur-Lesen",
|
||||
visitorModeDescription: "Nur-Lese-Modus. Ausgeblendete Videos sind für Besucher nicht sichtbar.",
|
||||
visitorModePasswordPrompt: "Bitte geben Sie das Website-Passwort ein, um die Besuchermodus-Einstellungen zu ändern.",
|
||||
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
|
||||
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
|
||||
|
||||
@@ -155,12 +158,14 @@ export const de = {
|
||||
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
publicUrl: "Öffentliche URL",
|
||||
publicUrlHelper: "Öffentliche Domain für den Dateizugriff (z.B. https://your-cloudflare-tunnel-domain.com). Wenn gesetzt, wird diese anstelle der API-URL für den Dateizugriff verwendet.",
|
||||
publicUrlHelper:
|
||||
"Öffentliche Domain für den Dateizugriff (z.B. https://your-cloudflare-tunnel-domain.com). Wenn gesetzt, wird diese anstelle der API-URL für den Dateizugriff verwendet.",
|
||||
uploadPath: "Upload-Pfad",
|
||||
cloudDrivePathHelper:
|
||||
"Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
|
||||
scanPaths: "Scan-Pfade",
|
||||
scanPathsHelper: "Ein Pfad pro Zeile. Videos werden von diesen Pfaden gescannt. Wenn leer, wird der Upload-Pfad verwendet. Beispiel:\n/a/Filme\n/b/Dokumentationen",
|
||||
scanPathsHelper:
|
||||
"Ein Pfad pro Zeile. Videos werden von diesen Pfaden gescannt. Wenn leer, wird der Upload-Pfad verwendet. Beispiel:\n/a/Filme\n/b/Dokumentationen",
|
||||
cloudDriveNote:
|
||||
"Nach Aktivierung dieser Funktion werden neu heruntergeladene Videos automatisch in den Cloud-Speicher hochgeladen und lokale Dateien werden gelöscht. Videos werden über einen Proxy aus dem Cloud-Speicher abgespielt.",
|
||||
cloudScanAdded: "Aus Cloud hinzugefügt",
|
||||
@@ -168,26 +173,36 @@ export const de = {
|
||||
testConnection: "Verbindung testen",
|
||||
sync: "Synchronisieren",
|
||||
syncToCloud: "Zwei-Wege-Synchronisierung",
|
||||
syncWarning: "Dieser Vorgang lädt lokale Videos in die Cloud hoch und sucht im Cloud-Speicher nach neuen Dateien. Lokale Dateien werden nach dem Upload gelöscht.",
|
||||
syncWarning:
|
||||
"Dieser Vorgang lädt lokale Videos in die Cloud hoch und sucht im Cloud-Speicher nach neuen Dateien. Lokale Dateien werden nach dem Upload gelöscht.",
|
||||
syncing: "Synchronisiere...",
|
||||
syncCompleted: "Synchronisation abgeschlossen",
|
||||
syncFailed: "Synchronisation fehlgeschlagen",
|
||||
syncReport: "Gesamt: {total} | Hochgeladen: {uploaded} | Fehlgeschlagen: {failed}",
|
||||
syncReport:
|
||||
"Gesamt: {total} | Hochgeladen: {uploaded} | Fehlgeschlagen: {failed}",
|
||||
syncErrors: "Fehler:",
|
||||
fillApiUrlToken: "Bitte füllen Sie zuerst API-URL und Token aus",
|
||||
connectionTestSuccess: "Verbindungstest erfolgreich! Einstellungen sind gültig.",
|
||||
connectionFailedStatus: "Verbindung fehlgeschlagen: Server gab Status {status} zurück",
|
||||
connectionFailedUrl: "Kann nicht mit Server verbinden. Bitte überprüfen Sie die API-URL.",
|
||||
authFailed: "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihr Token.",
|
||||
connectionTestSuccess:
|
||||
"Verbindungstest erfolgreich! Einstellungen sind gültig.",
|
||||
connectionFailedStatus:
|
||||
"Verbindung fehlgeschlagen: Server gab Status {status} zurück",
|
||||
connectionFailedUrl:
|
||||
"Kann nicht mit Server verbinden. Bitte überprüfen Sie die API-URL.",
|
||||
authFailed:
|
||||
"Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihr Token.",
|
||||
connectionTestFailed: "Verbindungstest fehlgeschlagen: {error}",
|
||||
syncFailedMessage: "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
foundVideosToSync: "{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
|
||||
syncFailedMessage:
|
||||
"Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
foundVideosToSync:
|
||||
"{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
|
||||
uploadingVideo: "Lade hoch: {title}",
|
||||
clearThumbnailCache: "Lokalen Thumbnail-Cache leeren",
|
||||
clearing: "Leeren...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
|
||||
clearThumbnailCacheSuccess:
|
||||
"Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
|
||||
clearThumbnailCacheError: "Fehler beim Leeren des Thumbnail-Caches",
|
||||
clearThumbnailCacheConfirmMessage: "Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
|
||||
clearThumbnailCacheConfirmMessage:
|
||||
"Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
|
||||
|
||||
manageContent: "Inhalte Verwalten",
|
||||
videos: "Videos",
|
||||
@@ -275,7 +290,8 @@ export const de = {
|
||||
openInExternalPlayer: "In externem Player öffnen",
|
||||
playWith: "Abspielen mit...",
|
||||
deleteAllFilteredVideos: "Alle gefilterten Videos löschen",
|
||||
confirmDeleteFilteredVideos: "Sind Sie sicher, dass Sie {count} Videos löschen möchten, die nach den ausgewählten Tags gefiltert wurden?",
|
||||
confirmDeleteFilteredVideos:
|
||||
"Sind Sie sicher, dass Sie {count} Videos löschen möchten, die nach den ausgewählten Tags gefiltert wurden?",
|
||||
deleteFilteredVideosSuccess: "Erfolgreich {count} Videos gelöscht.",
|
||||
deletingVideos: "Videos werden gelöscht...",
|
||||
signIn: "Anmelden",
|
||||
@@ -295,10 +311,39 @@ export const de = {
|
||||
resetPasswordConfirm: "Zurücksetzen",
|
||||
resetPasswordSuccess:
|
||||
"Das Passwort wurde zurückgesetzt. Überprüfen Sie die Backend-Protokolle für das neue Passwort.",
|
||||
resetPasswordDisabledInfo:
|
||||
"Die Passwort-Zurücksetzung ist deaktiviert. Um Ihr Passwort zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nDies generiert ein neues zufälliges Passwort und aktiviert die Passwort-Anmeldung.",
|
||||
resetPasswordScriptGuide:
|
||||
"Um das Passwort manuell zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nWenn kein Passwort angegeben wird, wird ein zufälliges 8-stelliges Passwort generiert.",
|
||||
waitTimeMessage: "Bitte warten Sie {time}, bevor Sie es erneut versuchen.",
|
||||
tooManyAttempts: "Zu viele fehlgeschlagene Versuche.",
|
||||
// Passkeys
|
||||
createPasskey: "Passkey erstellen",
|
||||
creatingPasskey: "Wird erstellt...",
|
||||
passkeyCreated: "Passkey erfolgreich erstellt",
|
||||
passkeyCreationFailed:
|
||||
"Passkey konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||
removePasskeys: "Alle Passkeys entfernen",
|
||||
removePasskeysTitle: "Alle Passkeys entfernen",
|
||||
removePasskeysMessage:
|
||||
"Sind Sie sicher, dass Sie alle Passkeys entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
passkeysRemoved: "Alle Passkeys wurden entfernt",
|
||||
passkeysRemoveFailed:
|
||||
"Passkeys konnten nicht entfernt werden. Bitte versuchen Sie es erneut.",
|
||||
loginWithPasskey: "Mit Passkey anmelden",
|
||||
authenticating: "Wird authentifiziert...",
|
||||
passkeyLoginFailed:
|
||||
"Passkey-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
passkeyErrorPermissionDenied:
|
||||
"Die Anfrage wird vom Benutzer-Agenten oder der Plattform im aktuellen Kontext nicht zugelassen, möglicherweise weil der Benutzer die Berechtigung verweigert hat.",
|
||||
passkeyErrorAlreadyRegistered:
|
||||
"Der Authentifikator wurde bereits zuvor registriert.",
|
||||
linkCopied: "Link in die Zwischenablage kopiert",
|
||||
copyFailed: "Link konnte nicht kopiert werden",
|
||||
passkeyRequiresHttps:
|
||||
"WebAuthn erfordert HTTPS oder localhost. Bitte greifen Sie über HTTPS auf die Anwendung zu oder verwenden Sie localhost anstelle einer IP-Adresse.",
|
||||
passkeyWebAuthnNotSupported:
|
||||
"WebAuthn wird in diesem Browser nicht unterstützt. Bitte verwenden Sie einen modernen Browser, der WebAuthn unterstützt.",
|
||||
loadingCollection: "Sammlung wird geladen...",
|
||||
collectionNotFound: "Sammlung nicht gefunden",
|
||||
noVideosInCollection: "Keine Videos in dieser Sammlung.",
|
||||
@@ -308,7 +353,8 @@ export const de = {
|
||||
unknownAuthor: "Unbekannt",
|
||||
noVideosForAuthor: "Keine Videos für diesen Autor gefunden.",
|
||||
deleteAuthor: "Autor löschen",
|
||||
deleteAuthorConfirmation: "Sind Sie sicher, dass Sie den Autor {author} löschen möchten? Dies löscht alle Videos dieses Autors.",
|
||||
deleteAuthorConfirmation:
|
||||
"Sind Sie sicher, dass Sie den Autor {author} löschen möchten? Dies löscht alle Videos dieses Autors.",
|
||||
authorDeletedSuccessfully: "Autor erfolgreich gelöscht",
|
||||
failedToDeleteAuthor: "Fehler beim Löschen des Autors",
|
||||
deleteCollectionTitle: "Sammlung Löschen",
|
||||
@@ -334,6 +380,11 @@ export const de = {
|
||||
unknownDate: "Unbekanntes Datum",
|
||||
part: "Teil",
|
||||
collection: "Sammlung",
|
||||
justNow: "Gerade eben",
|
||||
hoursAgo: "vor {hours} Stunden",
|
||||
today: "Heute",
|
||||
thisWeek: "Diese Woche",
|
||||
weeksAgo: "vor {weeks} Wochen",
|
||||
selectVideoFile: "Videodatei Auswählen",
|
||||
pleaseSelectVideo: "Bitte wählen Sie eine Videodatei aus",
|
||||
uploadFailed: "Upload fehlgeschlagen",
|
||||
@@ -346,7 +397,8 @@ export const de = {
|
||||
authorOrPlaylist: "Autor / Wiedergabeliste",
|
||||
playlistDetected: "Wiedergabeliste erkannt",
|
||||
playlistHasVideos: "Diese Wiedergabeliste hat {count} Videos.",
|
||||
downloadPlaylistAndCreateCollection: "Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
|
||||
downloadPlaylistAndCreateCollection:
|
||||
"Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
|
||||
collectionHasVideos: "Diese Bilibili-Sammlung hat {count} Videos.",
|
||||
seriesHasVideos: "Diese Bilibili-Serie hat {count} Videos.",
|
||||
videoHasParts: "Dieses Bilibili-Video hat {count} Teile.",
|
||||
@@ -418,7 +470,8 @@ export const de = {
|
||||
subscriptionAlreadyExists: "Sie haben diesen Autor bereits abonniert.",
|
||||
minutes: "Minuten",
|
||||
never: "Nie",
|
||||
downloadAllPreviousVideos: "Alle vorherigen Videos dieses Autors herunterladen",
|
||||
downloadAllPreviousVideos:
|
||||
"Alle vorherigen Videos dieses Autors herunterladen",
|
||||
downloadAllPreviousWarning:
|
||||
"Warnung: Dies lädt alle vorherigen Videos dieses Autors herunter. Dies kann erheblichen Speicherplatz verbrauchen und könnte Bot-Erkennungsmechanismen auslösen, die zu temporären oder dauerhaften Sperren der Plattform führen können. Verwenden Sie auf eigenes Risiko.",
|
||||
continuousDownloadTasks: "Kontinuierliche Download-Aufgaben",
|
||||
@@ -428,14 +481,17 @@ export const de = {
|
||||
taskStatusCancelled: "Abgebrochen",
|
||||
downloaded: "Heruntergeladen",
|
||||
cancelTask: "Aufgabe abbrechen",
|
||||
confirmCancelTask: "Sind Sie sicher, dass Sie die Download-Aufgabe für {author} abbrechen möchten?",
|
||||
confirmCancelTask:
|
||||
"Sind Sie sicher, dass Sie die Download-Aufgabe für {author} abbrechen möchten?",
|
||||
taskCancelled: "Aufgabe erfolgreich abgebrochen",
|
||||
deleteTask: "Aufgabe löschen",
|
||||
confirmDeleteTask: "Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
confirmDeleteTask:
|
||||
"Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
taskDeleted: "Aufgabe erfolgreich gelöscht",
|
||||
clearFinishedTasks: "Beendete Aufgaben löschen",
|
||||
tasksCleared: "Beendete Aufgaben erfolgreich gelöscht",
|
||||
confirmClearFinishedTasks: "Sind Sie sicher, dass Sie alle beendeten Aufgaben (abgeschlossen, abgebrochen) löschen möchten? Dies entfernt sie aus der Liste, löscht aber keine heruntergeladenen Dateien.",
|
||||
confirmClearFinishedTasks:
|
||||
"Sind Sie sicher, dass Sie alle beendeten Aufgaben (abgeschlossen, abgebrochen) löschen möchten? Dies entfernt sie aus der Liste, löscht aber keine heruntergeladenen Dateien.",
|
||||
clear: "Löschen",
|
||||
// Instruction Page
|
||||
instructionSection1Title: "1. Download & Aufgabenverwaltung",
|
||||
@@ -575,7 +631,8 @@ export const de = {
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Cloudflare Tunnel aktivieren",
|
||||
cloudflaredToken: "Tunnel-Token (Optional)",
|
||||
cloudflaredTokenHelper: "Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
|
||||
cloudflaredTokenHelper:
|
||||
"Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
|
||||
waitingForUrl: "Warte auf Quick Tunnel URL...",
|
||||
running: "Läuft",
|
||||
stopped: "Gestoppt",
|
||||
@@ -583,36 +640,60 @@ export const de = {
|
||||
accountTag: "Konto-Tag",
|
||||
copied: "Kopiert!",
|
||||
clickToCopy: "Zum Kopieren klicken",
|
||||
quickTunnelWarning: "Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
|
||||
managedInDashboard: "Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
|
||||
failedToDownloadVideo: "Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
|
||||
quickTunnelWarning:
|
||||
"Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
|
||||
managedInDashboard:
|
||||
"Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
|
||||
failedToDownloadVideo:
|
||||
"Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
|
||||
failedToDownload: "Fehler beim Herunterladen. Bitte versuchen Sie es erneut.",
|
||||
playlistDownloadStarted: "Playlist-Download gestartet",
|
||||
cleanupTempFilesConfirmMessage: "Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine Downloads aktiv sind, bevor Sie fortfahren.",
|
||||
cleanupTempFilesActiveDownloads: "Temporäre Dateien können nicht bereinigt werden, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie zuerst ab.",
|
||||
cleanupTempFilesConfirmMessage:
|
||||
"Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine Downloads aktiv sind, bevor Sie fortfahren.",
|
||||
cleanupTempFilesActiveDownloads:
|
||||
"Temporäre Dateien können nicht bereinigt werden, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie zuerst ab.",
|
||||
status: "Status",
|
||||
videoDownloading: "Video wird heruntergeladen",
|
||||
copyUrl: "URL kopieren",
|
||||
new: "NEU",
|
||||
// Task Hooks
|
||||
taskHooks: 'Aufgaben-Hoks',
|
||||
taskHooksDescription: 'Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.',
|
||||
hookTaskBeforeStart: 'Vor Aufgabenstart',
|
||||
hookTaskBeforeStartHelper: 'Wird ausgeführt, bevor der Download beginnt.',
|
||||
hookTaskSuccess: 'Aufgabe Erfolgreich',
|
||||
hookTaskSuccessHelper: 'Wird nach erfolgreichem Download ausgeführt, vor Cloud-Upload/Löschung (wartet auf Abschluss).',
|
||||
hookTaskFail: 'Aufgabe Fehlgeschlagen',
|
||||
hookTaskFailHelper: 'Wird ausgeführt, wenn eine Aufgabe fehlschlägt.',
|
||||
hookTaskCancel: 'Aufgabe Abgebrochen',
|
||||
hookTaskCancelHelper: 'Wird ausgeführt, wenn eine Aufgabe manuell abgebrochen wird.',
|
||||
found: 'Gefunden',
|
||||
notFound: 'Nicht Gesetzt',
|
||||
deleteHook: 'Hook-Skript Löschen',
|
||||
confirmDeleteHook: 'Sind Sie sicher, dass Sie dieses Hook-Skript löschen möchten?',
|
||||
uploadHook: 'Hochladen .sh',
|
||||
taskHooks: "Aufgaben-Hoks",
|
||||
taskHooksDescription:
|
||||
"Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
|
||||
taskHooksWarning:
|
||||
"Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.",
|
||||
enterPasswordToUploadHook:
|
||||
"Bitte geben Sie Ihr Passwort ein, um dieses Hook-Skript hochzuladen.",
|
||||
riskCommandDetected: "Risikobefehl erkannt: {command}. Upload abgelehnt.",
|
||||
hookTaskBeforeStart: "Vor Aufgabenstart",
|
||||
hookTaskBeforeStartHelper: "Wird ausgeführt, bevor der Download beginnt.",
|
||||
hookTaskSuccess: "Aufgabe Erfolgreich",
|
||||
hookTaskSuccessHelper:
|
||||
"Wird nach erfolgreichem Download ausgeführt, vor Cloud-Upload/Löschung (wartet auf Abschluss).",
|
||||
hookTaskFail: "Aufgabe Fehlgeschlagen",
|
||||
hookTaskFailHelper: "Wird ausgeführt, wenn eine Aufgabe fehlschlägt.",
|
||||
hookTaskCancel: "Aufgabe Abgebrochen",
|
||||
hookTaskCancelHelper:
|
||||
"Wird ausgeführt, wenn eine Aufgabe manuell abgebrochen wird.",
|
||||
found: "Gefunden",
|
||||
notFound: "Nicht Gesetzt",
|
||||
deleteHook: "Hook-Skript Löschen",
|
||||
confirmDeleteHook:
|
||||
"Sind Sie sicher, dass Sie dieses Hook-Skript löschen möchten?",
|
||||
uploadHook: "Hochladen .sh",
|
||||
|
||||
disclaimerTitle: "Haftungsausschluss",
|
||||
disclaimerText: "1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
|
||||
|
||||
disclaimerText:
|
||||
"1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
|
||||
// Visitor Mode
|
||||
admin: "Admin",
|
||||
visitorSignIn: "Besucher-Anmeldung",
|
||||
visitorUser: "Besucher-Benutzer",
|
||||
enableVisitorUser: "Besucher-Benutzer aktivieren",
|
||||
visitorUserHelper:
|
||||
"Aktivieren Sie ein separates Besucherkonto mit schreibgeschütztem Zugriff. Besucher können Inhalte ansehen, aber keine Änderungen vornehmen.",
|
||||
visitorPassword: "Besucher-Passwort",
|
||||
visitorPasswordHelper: "Legen Sie das Passwort für das Besucherkonto fest.",
|
||||
visitorPasswordSetHelper:
|
||||
"Passwort ist gesetzt. Leer lassen, um es zu behalten.",
|
||||
};
|
||||
|
||||
@@ -52,6 +52,12 @@ export const en = {
|
||||
videoColumns: "Video Columns (Homepage)",
|
||||
columnsCount: "{count} Columns",
|
||||
enableLogin: "Enable Login Protection",
|
||||
allowPasswordLogin: "Allow Password Login",
|
||||
allowPasswordLoginHelper:
|
||||
"When disabled, password login is not available. You must have at least one passkey to disable password login.",
|
||||
allowResetPassword: "Allow Reset Password",
|
||||
allowResetPasswordHelper:
|
||||
"When disabled, the reset password button will not be shown on the login page and the reset password API will be blocked.",
|
||||
password: "Password",
|
||||
enterPassword: "Enter Password",
|
||||
togglePasswordVisibility: "Toggle password visibility",
|
||||
@@ -116,22 +122,28 @@ export const en = {
|
||||
"This will permanently delete all .ytdl and .part files in the uploads directory. Make sure there are no active downloads before proceeding.",
|
||||
|
||||
// Task Hooks
|
||||
taskHooks: 'Task Hooks',
|
||||
taskHooksDescription: 'Execute custom shell commands at specific points in the task lifecycle. Available environment variables: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Warning: Commands run with the server\'s permissions. Use with caution.',
|
||||
hookTaskBeforeStart: 'Before Task Start',
|
||||
hookTaskBeforeStartHelper: 'Executes before the download begins.',
|
||||
hookTaskSuccess: 'Task Success',
|
||||
hookTaskSuccessHelper: 'Executes after successful download, before cloud upload/deletion (awaits completion).',
|
||||
hookTaskFail: 'Task Failed',
|
||||
hookTaskFailHelper: 'Executes when a task fails.',
|
||||
hookTaskCancel: 'Task Cancelled',
|
||||
hookTaskCancelHelper: 'Executes when a task is manually cancelled.',
|
||||
found: 'Found',
|
||||
notFound: 'Not Set',
|
||||
deleteHook: 'Delete Hook Script',
|
||||
confirmDeleteHook: 'Are you sure you want to delete this hook script?',
|
||||
uploadHook: 'Upload .sh',
|
||||
taskHooks: "Task Hooks",
|
||||
taskHooksDescription:
|
||||
"Execute custom shell commands at specific points in the task lifecycle. Available environment variables: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
|
||||
taskHooksWarning:
|
||||
"Warning: Commands run with the server's permissions. Use with caution.",
|
||||
hookTaskBeforeStart: "Before Task Start",
|
||||
hookTaskBeforeStartHelper: "Executes before the download begins.",
|
||||
hookTaskSuccess: "Task Success",
|
||||
hookTaskSuccessHelper:
|
||||
"Executes after successful download, before cloud upload/deletion (awaits completion).",
|
||||
hookTaskFail: "Task Failed",
|
||||
hookTaskFailHelper: "Executes when a task fails.",
|
||||
hookTaskCancel: "Task Cancelled",
|
||||
hookTaskCancelHelper: "Executes when a task is manually cancelled.",
|
||||
found: "Found",
|
||||
notFound: "Not Set",
|
||||
deleteHook: "Delete Hook Script",
|
||||
confirmDeleteHook: "Are you sure you want to delete this hook script?",
|
||||
uploadHook: "Upload .sh",
|
||||
enterPasswordToUploadHook:
|
||||
"Please enter your password to upload this hook script.",
|
||||
riskCommandDetected: "Risk command detected: {command}. Upload rejected.",
|
||||
cleanupTempFilesActiveDownloads:
|
||||
"Cannot clean up temporary files while downloads are active. Please wait for all downloads to complete or cancel them first.",
|
||||
formatFilenamesSuccess:
|
||||
@@ -142,12 +154,14 @@ export const en = {
|
||||
itemsPerPage: "Items Per Page",
|
||||
itemsPerPageHelper: "Number of videos to show per page (Default: 12)",
|
||||
showYoutubeSearch: "Show YouTube Search Results",
|
||||
visitorMode: "Visitor Mode (Read-only)",
|
||||
visitorModeReadOnly: "Visitor mode: Read-only",
|
||||
visitorModeDescription:
|
||||
"Read-only mode. Hidden videos will not be visible to visitors.",
|
||||
visitorModePasswordPrompt:
|
||||
"Please enter the website password to change Visitor Mode settings.",
|
||||
visitorUser: "Visitor User",
|
||||
enableVisitorUser: "Enable Visitor User",
|
||||
visitorUserHelper:
|
||||
"Enable a separate visitor account with read-only access. Visitors can view content but cannot make changes.",
|
||||
visitorPassword: "Visitor Password",
|
||||
visitorPasswordHelper: "Set the password for the visitor account.",
|
||||
visitorPasswordSetHelper: "Password is set. Leave empty to keep it.",
|
||||
cleanupTempFilesSuccess: "Successfully deleted {count} temporary file(s).",
|
||||
cleanupTempFilesFailed: "Failed to clean up temporary files",
|
||||
|
||||
@@ -208,9 +222,11 @@ export const en = {
|
||||
uploadingVideo: "Uploading: {title}",
|
||||
clearThumbnailCache: "Clear Thumbnail Local Cache",
|
||||
clearing: "Clearing...",
|
||||
clearThumbnailCacheSuccess: "Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheSuccess:
|
||||
"Thumbnail cache cleared successfully. Thumbnails will be regenerated when accessed next time.",
|
||||
clearThumbnailCacheError: "Failed to clear thumbnail cache",
|
||||
clearThumbnailCacheConfirmMessage: "This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
clearThumbnailCacheConfirmMessage:
|
||||
"This will clear all locally cached thumbnails for cloud videos. Thumbnails will be regenerated from cloud storage when accessed next time. Continue?",
|
||||
|
||||
// Manage
|
||||
manageContent: "Manage Content",
|
||||
@@ -306,6 +322,9 @@ export const en = {
|
||||
|
||||
// Login
|
||||
signIn: "Sign in",
|
||||
admin: "Admin",
|
||||
visitorSignIn: "Visitor Sign In",
|
||||
orVisitor: "OR VISITOR",
|
||||
verifying: "Verifying...",
|
||||
incorrectPassword: "Incorrect password",
|
||||
loginFailed: "Failed to verify password",
|
||||
@@ -322,8 +341,33 @@ export const en = {
|
||||
resetPasswordConfirm: "Reset",
|
||||
resetPasswordSuccess:
|
||||
"Password has been reset. Check backend logs for the new password.",
|
||||
resetPasswordDisabledInfo:
|
||||
"Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.",
|
||||
resetPasswordScriptGuide:
|
||||
"To reset password manually, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nIf no password is provided, a random 8-character password will be generated.",
|
||||
waitTimeMessage: "Please wait {time} before trying again.",
|
||||
tooManyAttempts: "Too many failed attempts.",
|
||||
// Passkeys
|
||||
createPasskey: "Create Passkey",
|
||||
creatingPasskey: "Creating...",
|
||||
passkeyCreated: "Passkey created successfully",
|
||||
passkeyCreationFailed: "Failed to create passkey. Please try again.",
|
||||
passkeyWebAuthnNotSupported:
|
||||
"WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.",
|
||||
passkeyRequiresHttps:
|
||||
"WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.",
|
||||
removePasskeys: "Remove All Passkeys",
|
||||
removePasskeysTitle: "Remove All Passkeys",
|
||||
removePasskeysMessage:
|
||||
"Are you sure you want to remove all passkeys? This action cannot be undone.",
|
||||
passkeysRemoved: "All passkeys have been removed",
|
||||
passkeysRemoveFailed: "Failed to remove passkeys. Please try again.",
|
||||
loginWithPasskey: "Login with Passkey",
|
||||
authenticating: "Authenticating...",
|
||||
passkeyLoginFailed: "Passkey authentication failed. Please try again.",
|
||||
passkeyErrorPermissionDenied:
|
||||
"The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.",
|
||||
passkeyErrorAlreadyRegistered: "The authenticator was previously registered.",
|
||||
linkCopied: "Link copied to clipboard",
|
||||
copyFailed: "Failed to copy link",
|
||||
copyUrl: "Copy URL",
|
||||
@@ -373,6 +417,11 @@ export const en = {
|
||||
part: "Part",
|
||||
collection: "Collection",
|
||||
new: "NEW",
|
||||
justNow: "Just now",
|
||||
hoursAgo: "{hours} hours ago",
|
||||
today: "Today",
|
||||
thisWeek: "This week",
|
||||
weeksAgo: "{weeks} weeks ago",
|
||||
|
||||
// Upload Modal
|
||||
selectVideoFile: "Select Video File",
|
||||
@@ -389,7 +438,8 @@ export const en = {
|
||||
authorOrPlaylist: "Author / Playlist",
|
||||
playlistDetected: "Playlist Detected",
|
||||
playlistHasVideos: "This playlist has {count} videos.",
|
||||
downloadPlaylistAndCreateCollection: "Download playlist videos and create a Collection for it?",
|
||||
downloadPlaylistAndCreateCollection:
|
||||
"Download playlist videos and create a Collection for it?",
|
||||
playlistDownloadStarted: "Playlist download started",
|
||||
collectionHasVideos: "This Bilibili collection has {count} videos.",
|
||||
seriesHasVideos: "This Bilibili series has {count} videos.",
|
||||
@@ -603,7 +653,8 @@ export const en = {
|
||||
cloudflaredTunnel: "Cloudflare Tunnel",
|
||||
enableCloudflaredTunnel: "Enable Cloudflare Tunnel",
|
||||
cloudflaredToken: "Tunnel Token (Optional)",
|
||||
cloudflaredTokenHelper: "Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
cloudflaredTokenHelper:
|
||||
"Paste your tunnel token here, or leave empty to use a random Quick Tunnel.",
|
||||
waitingForUrl: "Waiting for Quick Tunnel URL...",
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
@@ -611,8 +662,10 @@ export const en = {
|
||||
accountTag: "Account Tag",
|
||||
copied: "Copied!",
|
||||
clickToCopy: "Click to copy",
|
||||
quickTunnelWarning: "Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard: "Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
quickTunnelWarning:
|
||||
"Quick Tunnel URLs change every time the tunnel restarts.",
|
||||
managedInDashboard:
|
||||
"Public hostname is managed in your Cloudflare Zero Trust Dashboard.",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Export/Import Database",
|
||||
@@ -643,5 +696,4 @@ export const en = {
|
||||
noBackupAvailable: "No backup available",
|
||||
failedToDownloadVideo: "Failed to download video. Please try again.",
|
||||
failedToDownload: "Failed to download. Please try again.",
|
||||
|
||||
};
|
||||
|
||||
@@ -56,11 +56,18 @@ export const es = {
|
||||
websiteName: "Nombre del sitio web",
|
||||
websiteNameHelper: "{current}/{max} caracteres (Predeterminado: {default})",
|
||||
infiniteScroll: "Desplazamiento infinito",
|
||||
infiniteScrollDisabled: "Desactivado cuando el desplazamiento infinito está habilitado",
|
||||
infiniteScrollDisabled:
|
||||
"Desactivado cuando el desplazamiento infinito está habilitado",
|
||||
maxVideoColumns: "Columnas de video máximas (Página de inicio)",
|
||||
videoColumns: "Columnas de video (Página de inicio)",
|
||||
columnsCount: "{count} Columnas",
|
||||
enableLogin: "Habilitar Protección de Inicio de Sesión",
|
||||
allowPasswordLogin: "Permitir Inicio de Sesión con Contraseña",
|
||||
allowPasswordLoginHelper:
|
||||
"Cuando está deshabilitado, el inicio de sesión con contraseña no está disponible. Debe tener al menos una clave de acceso para deshabilitar el inicio de sesión con contraseña.",
|
||||
allowResetPassword: "Permitir Restablecer Contraseña",
|
||||
allowResetPasswordHelper:
|
||||
"Cuando está deshabilitado, el botón de restablecer contraseña no se mostrará en la página de inicio de sesión y la API de restablecer contraseña será bloqueada.",
|
||||
password: "Contraseña",
|
||||
enterPassword: "Introducir contraseña",
|
||||
togglePasswordVisibility: "Alternar visibilidad de contraseña",
|
||||
@@ -137,10 +144,7 @@ export const es = {
|
||||
itemsPerPageHelper:
|
||||
"Número de videos para mostrar por página (Predeterminado: 12)",
|
||||
showYoutubeSearch: "Mostrar resultados de búsqueda de YouTube",
|
||||
visitorMode: "Modo Visitante (Solo lectura)",
|
||||
visitorModeReadOnly: "Modo visitante: Solo lectura",
|
||||
visitorModeDescription: "Modo de solo lectura. Los videos ocultos no serán visibles para los visitantes.",
|
||||
visitorModePasswordPrompt: "Por favor, introduzca la contraseña del sitio web para cambiar la configuración del modo visitante.",
|
||||
cleanupTempFilesSuccess:
|
||||
"Se eliminaron exitosamente {count} archivo(s) temporal(es).",
|
||||
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
|
||||
@@ -170,11 +174,13 @@ export const es = {
|
||||
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
|
||||
token: "Token",
|
||||
publicUrl: "URL Público",
|
||||
publicUrlHelper: "Dominio público para acceder a archivos (ej. https://your-cloudflare-tunnel-domain.com). Si se establece, se usará en lugar de la URL de la API para acceder a archivos.",
|
||||
publicUrlHelper:
|
||||
"Dominio público para acceder a archivos (ej. https://your-cloudflare-tunnel-domain.com). Si se establece, se usará en lugar de la URL de la API para acceder a archivos.",
|
||||
uploadPath: "Ruta de carga",
|
||||
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
|
||||
scanPaths: "Rutas de escaneo",
|
||||
scanPathsHelper: "Una ruta por línea. Se escanearán videos de estas rutas. Si está vacío, se usará la ruta de carga. Ejemplo:\n/a/Peliculas\n/b/Documentales",
|
||||
scanPathsHelper:
|
||||
"Una ruta por línea. Se escanearán videos de estas rutas. Si está vacío, se usará la ruta de carga. Ejemplo:\n/a/Peliculas\n/b/Documentales",
|
||||
cloudDriveNote:
|
||||
"Después de habilitar esta función, los videos recién descargados se subirán automáticamente al almacenamiento en la nube y se eliminarán los archivos locales. Los videos se reproducirán desde el almacenamiento en la nube a través de un proxy.",
|
||||
cloudScanAdded: "Añadido desde la nube",
|
||||
@@ -182,26 +188,33 @@ export const es = {
|
||||
testConnection: "Probar Conexión",
|
||||
sync: "Sincronizar",
|
||||
syncToCloud: "Sincronización bidireccional",
|
||||
syncWarning: "Esta operación subirá videos locales a la nube y buscará nuevos archivos en el almacenamiento en la nube. Los archivos locales se eliminarán después de la carga.",
|
||||
syncWarning:
|
||||
"Esta operación subirá videos locales a la nube y buscará nuevos archivos en el almacenamiento en la nube. Los archivos locales se eliminarán después de la carga.",
|
||||
syncing: "Sincronizando...",
|
||||
syncCompleted: "Sincronización Completada",
|
||||
syncFailed: "Sincronización Fallida",
|
||||
syncReport: "Total: {total} | Cargados: {uploaded} | Fallidos: {failed}",
|
||||
syncErrors: "Errores:",
|
||||
fillApiUrlToken: "Por favor complete primero la URL de la API y el Token",
|
||||
connectionTestSuccess: "¡Prueba de conexión exitosa! La configuración es válida.",
|
||||
connectionFailedStatus: "Conexión fallida: El servidor devolvió el estado {status}",
|
||||
connectionFailedUrl: "No se puede conectar al servidor. Por favor verifique la URL de la API.",
|
||||
connectionTestSuccess:
|
||||
"¡Prueba de conexión exitosa! La configuración es válida.",
|
||||
connectionFailedStatus:
|
||||
"Conexión fallida: El servidor devolvió el estado {status}",
|
||||
connectionFailedUrl:
|
||||
"No se puede conectar al servidor. Por favor verifique la URL de la API.",
|
||||
authFailed: "Autentiación fallida. Por favor verifique su token.",
|
||||
connectionTestFailed: "Prueba de conexión fallida: {error}",
|
||||
syncFailedMessage: "Sincronización fallida. Por favor intente de nuevo.",
|
||||
foundVideosToSync: "Se encontraron {count} videos con archivos locales para sincronizar",
|
||||
foundVideosToSync:
|
||||
"Se encontraron {count} videos con archivos locales para sincronizar",
|
||||
uploadingVideo: "Subiendo: {title}",
|
||||
clearThumbnailCache: "Borrar caché local de miniaturas",
|
||||
clearing: "Borrando...",
|
||||
clearThumbnailCacheSuccess: "Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
|
||||
clearThumbnailCacheSuccess:
|
||||
"Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
|
||||
clearThumbnailCacheError: "Error al borrar el caché de miniaturas",
|
||||
clearThumbnailCacheConfirmMessage: "Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
|
||||
clearThumbnailCacheConfirmMessage:
|
||||
"Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
|
||||
|
||||
manageContent: "Gestionar Contenido",
|
||||
videos: "Videos",
|
||||
@@ -300,7 +313,8 @@ export const es = {
|
||||
openInExternalPlayer: "Abrir en reproductor externo",
|
||||
playWith: "Reproducir con...",
|
||||
deleteAllFilteredVideos: "Eliminar todos los videos filtrados",
|
||||
confirmDeleteFilteredVideos: "¿Está seguro de que desea eliminar {count} videos filtrados por las etiquetas seleccionadas?",
|
||||
confirmDeleteFilteredVideos:
|
||||
"¿Está seguro de que desea eliminar {count} videos filtrados por las etiquetas seleccionadas?",
|
||||
deleteFilteredVideosSuccess: "Se han eliminado {count} videos con éxito.",
|
||||
deletingVideos: "Eliminando videos...",
|
||||
signIn: "Iniciar Sesión",
|
||||
@@ -320,10 +334,39 @@ export const es = {
|
||||
resetPasswordConfirm: "Restablecer",
|
||||
resetPasswordSuccess:
|
||||
"La contraseña ha sido restablecida. Consulte los registros del backend para obtener la nueva contraseña.",
|
||||
resetPasswordDisabledInfo:
|
||||
"El restablecimiento de contraseña está deshabilitado. Para restablecer su contraseña, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nEsto generará una nueva contraseña aleatoria y habilitará el inicio de sesión con contraseña.",
|
||||
resetPasswordScriptGuide:
|
||||
"Para restablecer la contraseña manualmente, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nSi no se proporciona una contraseña, se generará una contraseña aleatoria de 8 caracteres.",
|
||||
waitTimeMessage: "Por favor espere {time} antes de intentar nuevamente.",
|
||||
tooManyAttempts: "Demasiados intentos fallidos.",
|
||||
// Passkeys
|
||||
createPasskey: "Crear clave de acceso",
|
||||
creatingPasskey: "Creando...",
|
||||
passkeyCreated: "Clave de acceso creada exitosamente",
|
||||
passkeyCreationFailed:
|
||||
"Error al crear la clave de acceso. Por favor, inténtelo de nuevo.",
|
||||
removePasskeys: "Eliminar todas las claves de acceso",
|
||||
removePasskeysTitle: "Eliminar todas las claves de acceso",
|
||||
removePasskeysMessage:
|
||||
"¿Está seguro de que desea eliminar todas las claves de acceso? Esta acción no se puede deshacer.",
|
||||
passkeysRemoved: "Todas las claves de acceso han sido eliminadas",
|
||||
passkeysRemoveFailed:
|
||||
"Error al eliminar las claves de acceso. Por favor, inténtelo de nuevo.",
|
||||
loginWithPasskey: "Iniciar sesión con clave de acceso",
|
||||
authenticating: "Autenticando...",
|
||||
passkeyLoginFailed:
|
||||
"Error en la autenticación con clave de acceso. Por favor, inténtelo de nuevo.",
|
||||
passkeyErrorPermissionDenied:
|
||||
"La solicitud no está permitida por el agente de usuario o la plataforma en el contexto actual, posiblemente porque el usuario denegó el permiso.",
|
||||
passkeyErrorAlreadyRegistered:
|
||||
"El autenticador ya estaba registrado previamente.",
|
||||
linkCopied: "Enlace copiado al portapapeles",
|
||||
copyFailed: "Error al copiar enlace",
|
||||
passkeyRequiresHttps:
|
||||
"WebAuthn requiere HTTPS o localhost. Por favor, acceda a la aplicación a través de HTTPS o utilice localhost en lugar de una dirección IP.",
|
||||
passkeyWebAuthnNotSupported:
|
||||
"WebAuthn no es compatible con este navegador. Por favor, utilice un navegador moderno que sea compatible con WebAuthn.",
|
||||
|
||||
// Collection Page: "Cargando colección...", collectionNotFound: "Colección no encontrada",
|
||||
noVideosInCollection: "No hay videos en esta colección.",
|
||||
@@ -333,7 +376,8 @@ export const es = {
|
||||
unknownAuthor: "Desconocido",
|
||||
noVideosForAuthor: "No se encontraron videos para este autor.",
|
||||
deleteAuthor: "Eliminar Autor",
|
||||
deleteAuthorConfirmation: "¿Está seguro de que desea eliminar al autor {author}? Esto eliminará todos los videos asociados con este autor.",
|
||||
deleteAuthorConfirmation:
|
||||
"¿Está seguro de que desea eliminar al autor {author}? Esto eliminará todos los videos asociados con este autor.",
|
||||
authorDeletedSuccessfully: "Autor eliminado con éxito",
|
||||
failedToDeleteAuthor: "Error al eliminar autor",
|
||||
deleteCollectionTitle: "Eliminar Colección",
|
||||
@@ -358,6 +402,11 @@ export const es = {
|
||||
unknownDate: "Fecha desconocida",
|
||||
part: "Parte",
|
||||
collection: "Colección",
|
||||
justNow: "Ahora mismo",
|
||||
hoursAgo: "Hace {hours} horas",
|
||||
today: "Hoy",
|
||||
thisWeek: "Esta semana",
|
||||
weeksAgo: "Hace {weeks} semanas",
|
||||
selectVideoFile: "Seleccionar Archivo de Video",
|
||||
pleaseSelectVideo: "Por favor seleccione un archivo de video",
|
||||
uploadFailed: "Carga fallida",
|
||||
@@ -370,7 +419,8 @@ export const es = {
|
||||
authorOrPlaylist: "Autor / Lista de reproducción",
|
||||
playlistDetected: "Lista de reproducción detectada",
|
||||
playlistHasVideos: "Esta lista de reproducción tiene {count} videos.",
|
||||
downloadPlaylistAndCreateCollection: "¿Descargar videos de la lista de reproducción y crear una colección para ella?",
|
||||
downloadPlaylistAndCreateCollection:
|
||||
"¿Descargar videos de la lista de reproducción y crear una colección para ella?",
|
||||
collectionHasVideos: "Esta colección de Bilibili tiene {count} videos.",
|
||||
seriesHasVideos: "Esta serie de Bilibili tiene {count} videos.",
|
||||
videoHasParts: "Este video de Bilibili tiene {count} partes.",
|
||||
@@ -433,7 +483,8 @@ export const es = {
|
||||
subscriptionAlreadyExists: "Ya estás suscrito a este autor.",
|
||||
minutes: "minutos",
|
||||
never: "Nunca",
|
||||
downloadAllPreviousVideos: "Descargar todos los videos anteriores de este autor",
|
||||
downloadAllPreviousVideos:
|
||||
"Descargar todos los videos anteriores de este autor",
|
||||
downloadAllPreviousWarning:
|
||||
"Advertencia: Esto descargará todos los videos anteriores de este autor. Esto puede consumir un espacio de almacenamiento significativo y podría activar mecanismos de detección de bots que pueden resultar en prohibiciones temporales o permanentes de la plataforma. Úselo bajo su propio riesgo.",
|
||||
continuousDownloadTasks: "Tareas de descarga continua",
|
||||
@@ -443,14 +494,17 @@ export const es = {
|
||||
taskStatusCancelled: "Cancelado",
|
||||
downloaded: "Descargado",
|
||||
cancelTask: "Cancelar tarea",
|
||||
confirmCancelTask: "¿Estás seguro de que quieres cancelar la tarea de descarga para {author}?",
|
||||
confirmCancelTask:
|
||||
"¿Estás seguro de que quieres cancelar la tarea de descarga para {author}?",
|
||||
taskCancelled: "Tarea cancelada exitosamente",
|
||||
deleteTask: "Eliminar tarea",
|
||||
confirmDeleteTask: "¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
|
||||
confirmDeleteTask:
|
||||
"¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
|
||||
taskDeleted: "Tarea eliminada exitosamente",
|
||||
clearFinishedTasks: "Borrar tareas finalizadas",
|
||||
tasksCleared: "Tareas finalizadas borradas con éxito",
|
||||
confirmClearFinishedTasks: "¿Está seguro de que desea borrar todas las tareas finalizadas (completadas, canceladas)? Esto las eliminará de la lista pero no borrará ningún archivo descargado.",
|
||||
confirmClearFinishedTasks:
|
||||
"¿Está seguro de que desea borrar todas las tareas finalizadas (completadas, canceladas)? Esto las eliminará de la lista pero no borrará ningún archivo descargado.",
|
||||
clear: "Borrar",
|
||||
// Instruction Page
|
||||
instructionSection1Title: "1. Descarga y Gestión de Tareas",
|
||||
@@ -581,7 +635,8 @@ export const es = {
|
||||
cloudflaredTunnel: "Túnel Cloudflare",
|
||||
enableCloudflaredTunnel: "Habilitar túnel Cloudflare",
|
||||
cloudflaredToken: "Token del túnel (Opcional)",
|
||||
cloudflaredTokenHelper: "Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
|
||||
cloudflaredTokenHelper:
|
||||
"Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
|
||||
waitingForUrl: "Esperando URL de Quick Tunnel...",
|
||||
running: "Ejecutando",
|
||||
stopped: "Detenido",
|
||||
@@ -589,8 +644,10 @@ export const es = {
|
||||
accountTag: "Etiqueta de cuenta",
|
||||
copied: "¡Copiado!",
|
||||
clickToCopy: "Clic para copiar",
|
||||
quickTunnelWarning: "Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
|
||||
managedInDashboard: "El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
|
||||
quickTunnelWarning:
|
||||
"Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
|
||||
managedInDashboard:
|
||||
"El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo: "Error al descargar el video. Inténtalo de nuevo.",
|
||||
failedToDownload: "Error al descargar. Inténtalo de nuevo.",
|
||||
playlistDownloadStarted: "Descarga de lista de reproducción iniciada",
|
||||
@@ -602,24 +659,44 @@ export const es = {
|
||||
copyUrl: "Copiar URL",
|
||||
new: "NUEVO",
|
||||
// Task Hooks
|
||||
taskHooks: 'Ganchos de Tarea',
|
||||
taskHooksDescription: 'Ejecute comandos de shell personalizados en puntos específicos del ciclo de vida de la tarea. Variables de entorno disponibles: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Advertencia: Los comandos se ejecutan con los permisos del servidor. Úselo con precaución.',
|
||||
hookTaskBeforeStart: 'Antes del Inicio de la Tarea',
|
||||
hookTaskBeforeStartHelper: 'Se ejecuta antes de que comience la descarga.',
|
||||
hookTaskSuccess: 'Tarea Exitosa',
|
||||
hookTaskSuccessHelper: 'Se ejecuta después de una descarga exitosa, antes de la carga/eliminación en la nube (espera finalización).',
|
||||
hookTaskFail: 'Tarea Fallida',
|
||||
hookTaskFailHelper: 'Se ejecuta cuando falla una tarea.',
|
||||
hookTaskCancel: 'Tarea Cancelada',
|
||||
hookTaskCancelHelper: 'Se ejecuta cuando una tarea se cancela manualmente.',
|
||||
found: 'Encontrado',
|
||||
notFound: 'No Establecido',
|
||||
deleteHook: 'Eliminar Script de Gancho',
|
||||
confirmDeleteHook: '¿Está seguro de que desea eliminar este script de gancho?',
|
||||
uploadHook: 'Subir .sh',
|
||||
taskHooks: "Ganchos de Tarea",
|
||||
taskHooksDescription:
|
||||
"Ejecute comandos de shell personalizados en puntos específicos del ciclo de vida de la tarea. Variables de entorno disponibles: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
|
||||
taskHooksWarning:
|
||||
"Advertencia: Los comandos se ejecutan con los permisos del servidor. Úselo con precaución.",
|
||||
hookTaskBeforeStart: "Antes del Inicio de la Tarea",
|
||||
hookTaskBeforeStartHelper: "Se ejecuta antes de que comience la descarga.",
|
||||
hookTaskSuccess: "Tarea Exitosa",
|
||||
hookTaskSuccessHelper:
|
||||
"Se ejecuta después de una descarga exitosa, antes de la carga/eliminación en la nube (espera finalización).",
|
||||
hookTaskFail: "Tarea Fallida",
|
||||
hookTaskFailHelper: "Se ejecuta cuando falla una tarea.",
|
||||
hookTaskCancel: "Tarea Cancelada",
|
||||
hookTaskCancelHelper: "Se ejecuta cuando una tarea se cancela manualmente.",
|
||||
found: "Encontrado",
|
||||
notFound: "No Establecido",
|
||||
deleteHook: "Eliminar Script de Gancho",
|
||||
confirmDeleteHook:
|
||||
"¿Está seguro de que desea eliminar este script de gancho?",
|
||||
uploadHook: "Subir .sh",
|
||||
|
||||
disclaimerTitle: "Descargo de responsabilidad",
|
||||
disclaimerText: "1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
|
||||
disclaimerText:
|
||||
"1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
|
||||
enterPasswordToUploadHook:
|
||||
"Por favor ingrese su contraseña para subir este script de gancho.",
|
||||
riskCommandDetected:
|
||||
"Comando de riesgo detectado: {command}. Carga rechazada.",
|
||||
|
||||
// Visitor Mode
|
||||
admin: "Administrador",
|
||||
visitorSignIn: "Inicio de Sesión de Visitante",
|
||||
visitorUser: "Usuario Visitante",
|
||||
enableVisitorUser: "Habilitar Usuario Visitante",
|
||||
visitorUserHelper:
|
||||
"Habilite una cuenta de visitante separada con acceso de solo lectura. Los visitantes pueden ver el contenido pero no pueden realizar cambios.",
|
||||
visitorPassword: "Contraseña de Visitante",
|
||||
visitorPasswordHelper: "Establezca la contraseña para la cuenta de visitante.",
|
||||
visitorPasswordSetHelper:
|
||||
"La contraseña está establecida. Déjelo en blanco para mantenerla.",
|
||||
};
|
||||
|
||||
@@ -55,6 +55,12 @@ export const fr = {
|
||||
videoColumns: "Colonnes vidéo (Accueil)",
|
||||
columnsCount: "{count} Colonnes",
|
||||
enableLogin: "Activer la protection par connexion",
|
||||
allowPasswordLogin: "Autoriser la connexion par mot de passe",
|
||||
allowPasswordLoginHelper:
|
||||
"Lorsqu'elle est désactivée, la connexion par mot de passe n'est pas disponible. Vous devez avoir au moins une clé d'accès pour désactiver la connexion par mot de passe.",
|
||||
allowResetPassword: "Autoriser la réinitialisation du mot de passe",
|
||||
allowResetPasswordHelper:
|
||||
"Lorsqu'elle est désactivée, le bouton de réinitialisation du mot de passe ne sera pas affiché sur la page de connexion et l'API de réinitialisation du mot de passe sera bloquée.",
|
||||
password: "Mot de passe",
|
||||
enterPassword: "Entrez le mot de passe",
|
||||
togglePasswordVisibility: "Afficher/Masquer le mot de passe",
|
||||
@@ -136,12 +142,7 @@ export const fr = {
|
||||
itemsPerPage: "Éléments par page",
|
||||
itemsPerPageHelper: "Nombre de vidéos à afficher par page (Défaut : 12)",
|
||||
showYoutubeSearch: "Afficher les résultats de recherche YouTube",
|
||||
visitorMode: "Mode Visiteur (Lecture seule)",
|
||||
visitorModeReadOnly: "Mode visiteur : Lecture seule",
|
||||
visitorModeDescription:
|
||||
"Mode lecture seule. Les vidéos masquées ne seront pas visibles pour les visiteurs.",
|
||||
visitorModePasswordPrompt:
|
||||
"Veuillez entrer le mot de passe du site web pour modifier les paramètres du mode visiteur.",
|
||||
cleanupTempFilesSuccess:
|
||||
"{count} fichier(s) temporaire(s) supprimé(s) avec succès.",
|
||||
cleanupTempFilesFailed: "Échec du nettoyage des fichiers temporaires",
|
||||
@@ -208,9 +209,11 @@ export const fr = {
|
||||
uploadingVideo: "Téléversement : {title}",
|
||||
clearThumbnailCache: "Vider le cache des miniatures locales",
|
||||
clearing: "Nettoyage...",
|
||||
clearThumbnailCacheSuccess: "Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
|
||||
clearThumbnailCacheSuccess:
|
||||
"Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
|
||||
clearThumbnailCacheError: "Échec du vidage du cache des miniatures",
|
||||
clearThumbnailCacheConfirmMessage: "Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
|
||||
clearThumbnailCacheConfirmMessage:
|
||||
"Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
|
||||
|
||||
// Manage
|
||||
manageContent: "Gérer le contenu",
|
||||
@@ -331,10 +334,39 @@ export const fr = {
|
||||
resetPasswordConfirm: "Réinitialiser",
|
||||
resetPasswordSuccess:
|
||||
"Le mot de passe a été réinitialisé. Consultez les journaux du backend pour le nouveau mot de passe.",
|
||||
resetPasswordDisabledInfo:
|
||||
"La réinitialisation du mot de passe est désactivée. Pour réinitialiser votre mot de passe, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nCela générera un nouveau mot de passe aléatoire et activera la connexion par mot de passe.",
|
||||
resetPasswordScriptGuide:
|
||||
"Pour réinitialiser le mot de passe manuellement, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nSi aucun mot de passe n'est fourni, un mot de passe aléatoire de 8 caractères sera généré.",
|
||||
waitTimeMessage: "Veuillez attendre {time} avant de réessayer.",
|
||||
tooManyAttempts: "Trop de tentatives échouées.",
|
||||
// Passkeys
|
||||
createPasskey: "Créer une clé d'accès",
|
||||
creatingPasskey: "Création en cours...",
|
||||
passkeyCreated: "Clé d'accès créée avec succès",
|
||||
passkeyCreationFailed:
|
||||
"Échec de la création de la clé d'accès. Veuillez réessayer.",
|
||||
removePasskeys: "Supprimer toutes les clés d'accès",
|
||||
removePasskeysTitle: "Supprimer toutes les clés d'accès",
|
||||
removePasskeysMessage:
|
||||
"Êtes-vous sûr de vouloir supprimer toutes les clés d'accès ? Cette action ne peut pas être annulée.",
|
||||
passkeysRemoved: "Toutes les clés d'accès ont été supprimées",
|
||||
passkeysRemoveFailed:
|
||||
"Échec de la suppression des clés d'accès. Veuillez réessayer.",
|
||||
loginWithPasskey: "Se connecter avec une clé d'accès",
|
||||
authenticating: "Authentification en cours...",
|
||||
passkeyLoginFailed:
|
||||
"Échec de l'authentification par clé d'accès. Veuillez réessayer.",
|
||||
passkeyErrorPermissionDenied:
|
||||
"La demande n'est pas autorisée par l'agent utilisateur ou la plateforme dans le contexte actuel, peut-être parce que l'utilisateur a refusé l'autorisation.",
|
||||
passkeyErrorAlreadyRegistered:
|
||||
"L'authentificateur a déjà été enregistré précédemment.",
|
||||
linkCopied: "Lien copié dans le presse-papiers",
|
||||
copyFailed: "Échec de la copie du lien",
|
||||
passkeyRequiresHttps:
|
||||
"WebAuthn nécessite HTTPS ou localhost. Veuillez accéder à l'application via HTTPS ou utiliser localhost au lieu d'une adresse IP.",
|
||||
passkeyWebAuthnNotSupported:
|
||||
"WebAuthn n'est pas supporté par ce navigateur. Veuillez utiliser un navigateur moderne qui supporte WebAuthn.",
|
||||
|
||||
// Collection Page
|
||||
loadingCollection: "Chargement de la collection...",
|
||||
@@ -381,6 +413,11 @@ export const fr = {
|
||||
unknownDate: "Date inconnue",
|
||||
part: "Partie",
|
||||
collection: "Collection",
|
||||
justNow: "À l'instant",
|
||||
hoursAgo: "Il y a {hours} heures",
|
||||
today: "Aujourd'hui",
|
||||
thisWeek: "Cette semaine",
|
||||
weeksAgo: "Il y a {weeks} semaines",
|
||||
|
||||
// Upload Modal
|
||||
selectVideoFile: "Sélectionner un fichier vidéo",
|
||||
@@ -397,7 +434,8 @@ export const fr = {
|
||||
authorOrPlaylist: "Auteur / Playlist",
|
||||
playlistDetected: "Playlist détectée",
|
||||
playlistHasVideos: "Cette playlist contient {count} vidéos.",
|
||||
downloadPlaylistAndCreateCollection: "Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
|
||||
downloadPlaylistAndCreateCollection:
|
||||
"Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
|
||||
collectionHasVideos: "Cette collection Bilibili contient {count} vidéos.",
|
||||
seriesHasVideos: "Cette série Bilibili contient {count} vidéos.",
|
||||
videoHasParts: "Cette vidéo Bilibili contient {count} parties.",
|
||||
@@ -484,7 +522,8 @@ export const fr = {
|
||||
taskDeleted: "Tâche supprimée avec succès",
|
||||
clearFinishedTasks: "Effacer les tâches terminées",
|
||||
tasksCleared: "Tâches terminées effacées avec succès",
|
||||
confirmClearFinishedTasks: "Êtes-vous sûr de vouloir effacer toutes les tâches terminées (complétées, annulées) ? Cela les supprimera de la liste mais ne supprimera aucun fichier téléchargé.",
|
||||
confirmClearFinishedTasks:
|
||||
"Êtes-vous sûr de vouloir effacer toutes les tâches terminées (complétées, annulées) ? Cela les supprimera de la liste mais ne supprimera aucun fichier téléchargé.",
|
||||
clear: "Effacer",
|
||||
// Instruction Page
|
||||
instructionSection1Title: "1. Téléchargement et Gestion des Tâches",
|
||||
@@ -628,7 +667,8 @@ export const fr = {
|
||||
cloudflaredTunnel: "Tunnel Cloudflare",
|
||||
enableCloudflaredTunnel: "Activer le tunnel Cloudflare",
|
||||
cloudflaredToken: "Jeton de tunnel (Optionnel)",
|
||||
cloudflaredTokenHelper: "Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
|
||||
cloudflaredTokenHelper:
|
||||
"Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
|
||||
waitingForUrl: "En attente de l'URL Quick Tunnel...",
|
||||
running: "En cours",
|
||||
stopped: "Arrêté",
|
||||
@@ -636,32 +676,54 @@ export const fr = {
|
||||
accountTag: "Tag de compte",
|
||||
copied: "Copié !",
|
||||
clickToCopy: "Cliquer pour copier",
|
||||
quickTunnelWarning: "Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
|
||||
managedInDashboard: "Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo: "Échec du téléchargement de la vidéo. Veuillez réessayer.",
|
||||
quickTunnelWarning:
|
||||
"Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
|
||||
managedInDashboard:
|
||||
"Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
|
||||
failedToDownloadVideo:
|
||||
"Échec du téléchargement de la vidéo. Veuillez réessayer.",
|
||||
failedToDownload: "Échec du téléchargement. Veuillez réessayer.",
|
||||
playlistDownloadStarted: "Téléchargement de la playlist commencé",
|
||||
copyUrl: "Copier l'URL",
|
||||
new: "NOUVEAU",
|
||||
// Task Hooks
|
||||
taskHooks: 'Crochets de Tâche',
|
||||
taskHooksDescription: 'Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d\'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
|
||||
taskHooksWarning: 'Avertissement : Les commandes s\'exécutent avec les permissions du serveur. À utiliser avec prudence.',
|
||||
hookTaskBeforeStart: 'Avant le Début de la Tâche',
|
||||
hookTaskBeforeStartHelper: 'S\'exécute avant le début du téléchargement.',
|
||||
hookTaskSuccess: 'Tâche Réussie',
|
||||
hookTaskSuccessHelper: 'S\'exécute après un téléchargement réussi, avant le téléchargement/suppression cloud (attend la fin).',
|
||||
hookTaskFail: 'Tâche Échouée',
|
||||
hookTaskFailHelper: 'S\'exécute lorsqu\'une tâche échoue.',
|
||||
hookTaskCancel: 'Tâche Annulée',
|
||||
hookTaskCancelHelper: 'S\'exécute lorsqu\'une tâche est annulée manuellement.',
|
||||
found: 'Trouvé',
|
||||
notFound: 'Non Défini',
|
||||
deleteHook: 'Supprimer le Script de Crochet',
|
||||
confirmDeleteHook: 'Êtes-vous sûr de vouloir supprimer ce script de crochet ?',
|
||||
uploadHook: 'Téléverser .sh',
|
||||
taskHooks: "Crochets de Tâche",
|
||||
taskHooksDescription:
|
||||
"Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
|
||||
taskHooksWarning:
|
||||
"Avertissement : Les commandes s'exécutent avec les permissions du serveur. À utiliser avec prudence.",
|
||||
enterPasswordToUploadHook:
|
||||
"Veuillez entrer votre mot de passe pour télécharger ce script Hook.",
|
||||
riskCommandDetected:
|
||||
"Commande à risque détectée : {command}. Téléchargement rejeté.",
|
||||
hookTaskBeforeStart: "Avant le Début de la Tâche",
|
||||
hookTaskBeforeStartHelper: "S'exécute avant le début du téléchargement.",
|
||||
hookTaskSuccess: "Tâche Réussie",
|
||||
hookTaskSuccessHelper:
|
||||
"S'exécute après un téléchargement réussi, avant le téléchargement/suppression cloud (attend la fin).",
|
||||
hookTaskFail: "Tâche Échouée",
|
||||
hookTaskFailHelper: "S'exécute lorsqu'une tâche échoue.",
|
||||
hookTaskCancel: "Tâche Annulée",
|
||||
hookTaskCancelHelper: "S'exécute lorsqu'une tâche est annulée manuellement.",
|
||||
found: "Trouvé",
|
||||
notFound: "Non Défini",
|
||||
deleteHook: "Supprimer le Script de Crochet",
|
||||
confirmDeleteHook:
|
||||
"Êtes-vous sûr de vouloir supprimer ce script de crochet ?",
|
||||
uploadHook: "Téléverser .sh",
|
||||
|
||||
disclaimerTitle: "Avis de non-responsabilité",
|
||||
disclaimerText: "1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
|
||||
|
||||
disclaimerText:
|
||||
"1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
|
||||
// Visitor Mode
|
||||
admin: "Administrateur",
|
||||
visitorSignIn: "Connexion Visiteur",
|
||||
visitorUser: "Utilisateur Visiteur",
|
||||
enableVisitorUser: "Activer l'Utilisateur Visiteur",
|
||||
visitorUserHelper:
|
||||
"Activez un compte visiteur séparé avec un accès en lecture seule. Les visiteurs peuvent voir le contenu mais ne peuvent pas effectuer de modifications.",
|
||||
visitorPassword: "Mot de passe Visiteur",
|
||||
visitorPasswordHelper: "Définissez le mot de passe pour le compte visiteur.",
|
||||
visitorPasswordSetHelper:
|
||||
"Le mot de passe est défini. Laisser vide pour le conserver.",
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user