diff --git a/compose.yaml b/compose.yaml index e7d079f..fc365e5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ # ============================================================================== # ConvertX-CN 部署範例(docker-compose.yml / compose.yaml) -# +# # 🎉 這是完整版 image,已內建所有轉換依賴: # - LibreOffice (headless) # - TexLive Full @@ -23,19 +23,19 @@ services: - ./data:/app/data environment: # === 帳號設定 === - - ACCOUNT_REGISTRATION=false # 是否允許註冊新帳號(首次帳號不受此限制) - - JWT_SECRET=請更換為一個長且隨機的字串 # 若不設定則使用 randomUUID() - + - ACCOUNT_REGISTRATION=false # 是否允許註冊新帳號(首次帳號不受此限制) + - JWT_SECRET=請更換為一個長且隨機的字串 # 若不設定則使用 randomUUID() + # === 安全設定 === - - HTTP_ALLOWED=false # 是否允許非 HTTPS 連線(僅本地測試時設為 true) - - ALLOW_UNAUTHENTICATED=false # 是否允許未登入使用(僅本地測試時設為 true) - + - HTTP_ALLOWED=false # 是否允許非 HTTPS 連線(僅本地測試時設為 true) + - ALLOW_UNAUTHENTICATED=false # 是否允許未登入使用(僅本地測試時設為 true) + # === 檔案管理 === - - AUTO_DELETE_EVERY_N_HOURS=24 # 自動刪除超過 N 小時的檔案(0 = 停用) - + - AUTO_DELETE_EVERY_N_HOURS=24 # 自動刪除超過 N 小時的檔案(0 = 停用) + # === 時區設定 === - TZ=Asia/Taipei - + # === 可選設定 === # - WEBROOT=/convertx # 子路徑部署(例如 example.com/convertx/) # - HIDE_HISTORY=true # 隱藏歷史紀錄頁面 diff --git a/docs/faq.md b/docs/faq.md index 585d318..8c3b88c 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -5,8 +5,9 @@ ### Q: 如何註冊帳號? 首次訪問 ConvertX-CN 時: + 1. 開啟 `http://localhost:3000` -2. 點擊右上角 **Register** +2. 點擊右上角 **Register** 3. 輸入 Email 和密碼 4. 完成!系統會自動登入 @@ -26,6 +27,7 @@ environment: ### Q: 忘記密碼怎麼辦? 目前版本尚未提供密碼重設功能。您可以: + 1. 刪除 `data/convertx.db` 2. 重新啟動容器 3. 重新註冊 @@ -39,6 +41,7 @@ environment: ### Q: 為什麼 Image 這麼大(4-6 GB)? ConvertX-CN 是「完整版」,內建: + - LibreOffice(文件轉換) - TexLive(LaTeX 支援) - FFmpeg(影音轉換) @@ -46,11 +49,13 @@ ConvertX-CN 是「完整版」,內建: - CJK 字型 如果您只需要基本功能,可使用原作者的輕量版: + - `ghcr.io/c4illin/convertx:latest` ### Q: Docker 啟動失敗? 常見原因: + 1. **Port 被占用**:改用其他 port,如 `-p 3001:3000` 2. **磁碟空間不足**:Image 需約 6GB 3. **權限問題**:確保 `./data` 資料夾有寫入權限 @@ -62,6 +67,7 @@ chmod -R 777 ./data ### Q: 資料存在哪裡? 所有資料存放在掛載的 `/app/data` 目錄: + - `convertx.db` - SQLite 資料庫 - `uploads/` - 上傳的原始檔案 - `output/` - 轉換後的檔案 @@ -97,6 +103,7 @@ chmod -R 777 ./data ### Q: 支援哪些格式? ConvertX-CN 支援數百種格式,包括: + - **影音**:MP4, AVI, MKV, MP3, WAV 等 - **圖片**:PNG, JPG, WEBP, SVG, PDF 等 - **文件**:DOCX, PDF, XLSX, PPTX 等 @@ -116,6 +123,7 @@ ConvertX-CN 支援數百種格式,包括: ### Q: 檔案大小有限制嗎? 預設無硬性限制,但大檔案: + - 上傳需要更長時間 - 轉換可能耗用大量記憶體 diff --git a/docs/getting-started.md b/docs/getting-started.md index 2534a46..04c097c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -35,6 +35,7 @@ docker run -d \ ``` 開啟瀏覽器訪問 `http://localhost:3000`: + 1. 點擊右上角 **Register** 註冊帳號 2. 輸入 Email 和密碼 3. 完成!開始使用 diff --git a/eslint.config.ts b/eslint.config.ts index 8ede127..d361883 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -61,6 +61,8 @@ export default tseslint.config( "target", "convert_to_target", "job-details-toggle", + "language-selector", + "language-option", ], }, ], diff --git a/knip.json b/knip.json index 4512911..88a90af 100644 --- a/knip.json +++ b/knip.json @@ -1,8 +1,11 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["tests/**/*.test.ts"], + "entry": ["tests/**/*.test.ts", "src/i18n/index.ts"], "project": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"], "tailwind": { "entry": ["src/main.css"] - } + }, + "ignore": ["src/i18n/**"], + "ignoreDependencies": [], + "ignoreExportsUsedInFile": true } diff --git a/public/i18n.js b/public/i18n.js index 7b9718c..18f92fd 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -90,7 +90,7 @@ options.forEach((option) => { option.addEventListener("click", () => { const locale = option.dataset.locale; - const webroot = option.dataset.webroot || ""; + // webroot is available in option.dataset.webroot if needed for future use // Save to cookie setCookie(LOCALE_COOKIE_NAME, locale, LOCALE_EXPIRY_DAYS); diff --git a/src/components/base.tsx b/src/components/base.tsx index d13a8b2..561c4e2 100644 --- a/src/components/base.tsx +++ b/src/components/base.tsx @@ -24,9 +24,7 @@ export const BaseHtml = ({ - + - - - ); - }, - { - auth: true, - }, -); + + + + ); + }, + { + auth: true, + }, + ); diff --git a/src/pages/results.tsx b/src/pages/results.tsx index 1fa0cf4..a182c45 100644 --- a/src/pages/results.tsx +++ b/src/pages/results.tsx @@ -7,7 +7,7 @@ import { ALLOW_UNAUTHENTICATED, WEBROOT } from "../helpers/env"; import { DownloadIcon } from "../icons/download"; import { DeleteIcon } from "../icons/delete"; import { EyeIcon } from "../icons/eye"; -import { type SupportedLocale, type Translator, createTranslator, defaultLocale } from "../i18n/index"; +import { type Translator, createTranslator, defaultLocale } from "../i18n/index"; import { localeService } from "../i18n/service"; import { userService } from "./user"; @@ -165,7 +165,13 @@ export const results = new Elysia() return ( <> -
+
{ - if (!ALLOW_UNAUTHENTICATED) { - if (FIRST_RUN) { - return redirect(`${WEBROOT}/setup`, 302); +export const root = new Elysia() + .use(userService) + .use(localeService) + .get( + "/", + async ({ jwt, redirect, cookie: { auth, jobId }, locale, t }) => { + if (!ALLOW_UNAUTHENTICATED) { + if (FIRST_RUN) { + return redirect(`${WEBROOT}/setup`, 302); + } + + if (!auth?.value) { + return redirect(`${WEBROOT}/login`, 302); + } } - if (!auth?.value) { + // validate jwt + let user: ({ id: string } & JWTPayloadSpec) | false = false; + if (ALLOW_UNAUTHENTICATED) { + const newUserId = String( + UNAUTHENTICATED_USER_SHARING + ? 0 + : randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)), + ); + const accessToken = await jwt.sign({ + id: newUserId, + }); + + user = { id: newUserId }; + if (!auth) { + return { + message: t("auth", "noCookies"), + }; + } + + // set cookie + auth.set({ + value: accessToken, + httpOnly: true, + secure: !HTTP_ALLOWED, + maxAge: 24 * 60 * 60, + sameSite: "strict", + }); + } else if (auth?.value) { + user = await jwt.verify(auth.value); + + if ( + user !== false && + user.id && + (Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED) + ) { + // Make sure user exists in db + const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id); + + if (!existingUser) { + if (auth?.value) { + auth.remove(); + } + return redirect(`${WEBROOT}/login`, 302); + } + } + } + + if (!user) { return redirect(`${WEBROOT}/login`, 302); } - } - // validate jwt - let user: ({ id: string } & JWTPayloadSpec) | false = false; - if (ALLOW_UNAUTHENTICATED) { - const newUserId = String( - UNAUTHENTICATED_USER_SHARING - ? 0 - : randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)), + // create a new job + db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run( + user.id, + new Date().toISOString(), ); - const accessToken = await jwt.sign({ - id: newUserId, - }); - user = { id: newUserId }; - if (!auth) { - return { - message: t("auth", "noCookies"), - }; + const { id } = db + .query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC") + .get(user.id) as { id: number }; + + if (!jobId) { + return { message: t("auth", "cookiesRequired") }; } - // set cookie - auth.set({ - value: accessToken, + jobId.set({ + value: id, httpOnly: true, secure: !HTTP_ALLOWED, maxAge: 24 * 60 * 60, sameSite: "strict", }); - } else if (auth?.value) { - user = await jwt.verify(auth.value); - if ( - user !== false && - user.id && - (Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED) - ) { - // Make sure user exists in db - const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id); + console.log("jobId set to:", id); - if (!existingUser) { - if (auth?.value) { - auth.remove(); - } - return redirect(`${WEBROOT}/login`, 302); - } - } - } - - if (!user) { - return redirect(`${WEBROOT}/login`, 302); - } - - // create a new job - db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run( - user.id, - new Date().toISOString(), - ); - - const { id } = db - .query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC") - .get(user.id) as { id: number }; - - if (!jobId) { - return { message: t("auth", "cookiesRequired") }; - } - - jobId.set({ - value: id, - httpOnly: true, - secure: !HTTP_ALLOWED, - maxAge: 24 * 60 * 60, - sameSite: "strict", - }); - - console.log("jobId set to:", id); - - return ( - - <> -
-
-
-

{t("convert", "title")}

-
- - -
- - {t("convert", "chooseFile")} {t("convert", "orDragHere")} - - -
- - + <> +
+
- -
- -
-
+

{t("convert", "title")}

+
+
- {Object.entries(getAllTargets()).map(([converter, targets]) => ( -
-
- {converter} -
-
    - {targets.map((target) => ( - - ))} -
-
- ))} - - - {/* Hidden element which determines the format to convert the file too and the converter to use */} - + /> + +
+ + {t("convert", "chooseFile")} {t("convert", "orDragHere")} + +
- - - -