diff --git a/.env.sample b/.env.sample index 071d49f..6207dbc 100644 --- a/.env.sample +++ b/.env.sample @@ -15,6 +15,8 @@ AI_BASE_URL="https://api.openai.com/v1" # Base URL for OpenAI-compatible API AI_MODEL="gpt-4o-mini" # Model used for parsing search queries in "Find" # Immich Share Link -IMMICH_SHARE_LINK_KEY="" # Share link key for Immich POWER_TOOLS_ENDPOINT_URL="" # URL of the Power Tools endpoint (Used for share links) JWT_SECRET="" # JWT Secret for authentication + +# App Database (SQLite) +APP_DB_PATH="" # Path to the SQLite database file (default: ./data/app.db) diff --git a/package.json b/package.json index 9301dc6..15ac611 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@ai-sdk/openai": "^3.0.29", "@ai-sdk/openai-compatible": "^2.0.30", "@ai-sdk/react": "^3.0.88", + "@libsql/client": "^0.17.2", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.0", @@ -58,7 +59,7 @@ "cookie": "^0.6.0", "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "^0.45.1", "jsonwebtoken": "^9.0.2", "leaflet": "^1.9.4", "leaflet-defaulticon-compatibility": "0.1.2", @@ -96,7 +97,7 @@ "@types/react-dom": "^18", "@types/react-mentions": "^4.4.1", "autoprefixer": "^10.4.20", - "drizzle-kit": "^0.24.0", + "drizzle-kit": "^0.31.10", "eslint": "^8", "eslint-config-next": "14.2.5", "postcss": "^8.4.41", diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx index 31e05db..8b90d36 100644 --- a/src/components/shared/ShareAssetsTrigger.tsx +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -22,6 +22,30 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets const [config, setConfig] = useState>({ expiresIn: "never" }); + const [endpointConfigured, setEndpointConfigured] = useState(null); + const [shareKeyExists, setShareKeyExists] = useState(null); + const [creatingKey, setCreatingKey] = useState(false); + const [keyError, setKeyError] = useState(null); + + const checkShareKey = async () => { + const res = await fetch('/api/share-key'); + const data = await res.json(); + setEndpointConfigured(data.endpointConfigured); + setShareKeyExists(data.exists); + }; + + const handleCreateKey = async () => { + setCreatingKey(true); + setKeyError(null); + const res = await fetch('/api/share-key', { method: 'POST' }); + if (res.ok) { + setShareKeyExists(true); + } else { + const data = await res.json(); + setKeyError(data.message ?? 'Failed to create share key'); + } + setCreatingKey(false); + }; const handleReset = () => { setConfig({ expiresIn: "never" }); @@ -52,7 +76,7 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets return ( - + { if (open) checkShareKey(); }}> @@ -63,6 +87,24 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets Generate a share link for the selected assets (either bunch of albums or bunch of people) and share it with your friends and family. + {endpointConfigured === false && ( +
+

+ POWER_TOOLS_ENDPOINT_URL is not set. Add it to your environment variables to enable share links. +

+
+ )} + {endpointConfigured && shareKeyExists === false && ( +
+

+ Serving thumbnails on share links requires a dedicated API key. Create one now with minimal permissions. +

+ {keyError &&

{keyError}

} + +
+ )} {errorMessage &&
{errorMessage}
} {generatedLink ?
@@ -106,7 +148,7 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets
- + )} diff --git a/src/config/constants/sidebarNavs.tsx b/src/config/constants/sidebarNavs.tsx index 27c1834..78d8655 100644 --- a/src/config/constants/sidebarNavs.tsx +++ b/src/config/constants/sidebarNavs.tsx @@ -1,4 +1,4 @@ -import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, Share2, User, Video } from "lucide-react"; +import { Copy, GalleryHorizontal, GalleryVerticalEnd, Image as ImageIcon, MapPin, MapPinX, Rewind, Search, Settings, Share2, User, Video } from "lucide-react"; export const sidebarNavs = [ // { @@ -59,5 +59,9 @@ export const sidebarNavs = [ link: "/assets/bulk-duplicate-finder", icon: , }, - + { + title: "Settings", + link: "/settings", + icon: , + }, ]; diff --git a/src/config/environment.ts b/src/config/environment.ts index f3daf06..bc0c5c3 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -15,8 +15,9 @@ export const ENV = { AI_API_KEY: process.env.AI_API_KEY as string, AI_BASE_URL: (process.env.AI_BASE_URL || 'https://api.openai.com/v1') as string, AI_MODEL: process.env.AI_MODEL as string, - IMMICH_SHARE_LINK_KEY: process.env.IMMICH_SHARE_LINK_KEY as string, POWER_TOOLS_ENDPOINT_URL: process.env.POWER_TOOLS_ENDPOINT_URL as string, + // App SQLite database path (defaults to ./data/app.db) + APP_DB_PATH: process.env.APP_DB_PATH as string | undefined, // OAuth configuration OAUTH_ENABLED: process.env.OAUTH_ENABLED === 'true', OAUTH_BUTTON_TEXT: (process.env.OAUTH_BUTTON_TEXT || 'Login with OAuth') as string, diff --git a/src/db/migrations/0000_vengeful_kylun.sql b/src/db/migrations/0000_vengeful_kylun.sql new file mode 100644 index 0000000..b60b53d --- /dev/null +++ b/src/db/migrations/0000_vengeful_kylun.sql @@ -0,0 +1,17 @@ +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `purpose` text NOT NULL, + `key_name` text NOT NULL, + `secret` text NOT NULL, + `immich_key_id` text NOT NULL, + `created_at` integer +); +--> statement-breakpoint +CREATE TABLE `settings` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `updated_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `api_keys_user_id_purpose_unique` ON `api_keys` (`user_id`,`purpose`); \ No newline at end of file diff --git a/src/db/migrations/meta/0000_snapshot.json b/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..f17cfa1 --- /dev/null +++ b/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,114 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8b257fc8-972b-47da-ae98-7a83316d6df6", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_name": { + "name": "key_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "immich_key_id": { + "name": "immich_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_user_id_purpose_unique": { + "name": "api_keys_user_id_purpose_unique", + "columns": [ + "user_id", + "purpose" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 7e7f287..655df45 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -1,5 +1,13 @@ { "version": "5", "dialect": "sqlite", - "entries": [] -} + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1774242821856, + "tag": "0000_vengeful_kylun", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/db/schema/apiKeys.schema.ts b/src/db/schema/apiKeys.schema.ts new file mode 100644 index 0000000..e931daa --- /dev/null +++ b/src/db/schema/apiKeys.schema.ts @@ -0,0 +1,14 @@ +import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core"; +import { randomUUID } from "crypto"; + +export const apiKeys = sqliteTable("api_keys", { + id: text("id").primaryKey().$defaultFn(() => randomUUID()), + userId: text("user_id").notNull(), + purpose: text("purpose").notNull(), + keyName: text("key_name").notNull(), + secret: text("secret").notNull(), + immichKeyId: text("immich_key_id").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), +}, (t) => [ + unique().on(t.userId, t.purpose), +]); diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index a58fe37..5c0e3da 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -1 +1,2 @@ export * from "./settings.schema"; +export * from "./apiKeys.schema"; diff --git a/src/helpers/user.helper.ts b/src/helpers/user.helper.ts index 6713b45..b34e6cb 100644 --- a/src/helpers/user.helper.ts +++ b/src/helpers/user.helper.ts @@ -3,7 +3,6 @@ import { IUser } from "@/types/user" export const getUserHeaders = (user: { isUsingAPIKey?: boolean, - isUsingShareKey?: boolean, accessToken?: string }, otherHeaders?: { 'Content-Type': string @@ -15,9 +14,7 @@ export const getUserHeaders = (user: { } = { 'Content-Type': 'application/json', } - if (user.isUsingShareKey) { - headers['x-api-key'] = ENV.IMMICH_SHARE_LINK_KEY - } else if (user.isUsingAPIKey) { + if (user.isUsingAPIKey) { headers['x-api-key'] = ENV.IMMICH_API_KEY } else { headers['Authorization'] = `Bearer ${user.accessToken}` diff --git a/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts index 4fa6c77..2d3526a 100644 --- a/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts +++ b/src/pages/api/immich-proxy/asset/share-thumbnail/[id].ts @@ -1,7 +1,9 @@ // pages/api/proxy.js import { ENV } from '@/config/environment'; -import { getUserHeaders } from '@/helpers/user.helper'; +import { appDb } from '@/db'; +import { apiKeys } from '@/db/schema'; +import { and, eq } from 'drizzle-orm'; import { verify } from 'jsonwebtoken'; import { NextApiRequest, NextApiResponse } from 'next' @@ -16,18 +18,31 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method !== 'GET') { return res.status(405).json({ message: 'Method Not Allowed' }) } - + const { id, size, token, p } = req.query; if (!token) { return res.status(401).json({ message: 'Unauthorized' }) } + let userId: string; try { - verify(token as string, ENV.JWT_SECRET); + const previewDecoded = verify(token as string, ENV.JWT_SECRET) as { token: string }; + const mainDecoded = verify(previewDecoded.token, ENV.JWT_SECRET) as { userId: string }; + userId = mainDecoded.userId; } catch (error) { return res.status(401).json({ message: 'Token is invalid' }) } + const [keyRow] = await appDb + .select({ secret: apiKeys.secret }) + .from(apiKeys) + .where(and(eq(apiKeys.userId, userId), eq(apiKeys.purpose, "share"))) + .limit(1); + + if (!keyRow) { + return res.status(403).json({ message: 'Share key not configured. Please set up sharing in Power Tools.' }); + } + const resource = p === "true" ? "people" : "assets"; const baseURL = `${ENV.IMMICH_URL}/api/${resource}/${id}`; const version = size === "original" ? "original" : "thumbnail"; @@ -37,9 +52,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Forward the request to the target API const response = await fetch(targetUrl, { method: 'GET', - headers: getUserHeaders({ isUsingShareKey: true }, { + headers: { 'Content-Type': 'application/octet-stream', - }), + 'x-api-key': keyRow.secret, + }, }) if (!response.ok) { diff --git a/src/pages/api/settings/api-keys/[purpose].ts b/src/pages/api/settings/api-keys/[purpose].ts new file mode 100644 index 0000000..aeb60a0 --- /dev/null +++ b/src/pages/api/settings/api-keys/[purpose].ts @@ -0,0 +1,93 @@ +import { ENV } from "@/config/environment"; +import { appDb } from "@/db"; +import { apiKeys } from "@/db/schema"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { getUserHeaders } from "@/helpers/user.helper"; +import { and, eq } from "drizzle-orm"; +import { NextApiRequest, NextApiResponse } from "next"; + +const PURPOSE_CONFIG: Record = { + share: { + keyName: "Power Tools Share Key", + permissions: ["asset.view", "asset.download"], + }, +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const currentUser = await getCurrentUser(req); + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const { purpose } = req.query as { purpose: string }; + + if (req.method === "DELETE") { + const [row] = await appDb + .select() + .from(apiKeys) + .where(and(eq(apiKeys.userId, currentUser.id), eq(apiKeys.purpose, purpose))) + .limit(1); + + if (!row) { + return res.status(404).json({ message: "API key not found" }); + } + + await fetch(`${ENV.IMMICH_URL}/api/api-keys/${row.immichKeyId}`, { + method: "DELETE", + headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }), + }); + + await appDb + .delete(apiKeys) + .where(and(eq(apiKeys.userId, currentUser.id), eq(apiKeys.purpose, purpose))); + + return res.status(200).json({ success: true }); + } + + if (req.method === "PUT") { + const config = PURPOSE_CONFIG[purpose]; + if (!config) { + return res.status(400).json({ message: `Unknown purpose: ${purpose}` }); + } + + // Delete old key from Immich if it exists + const [existing] = await appDb + .select() + .from(apiKeys) + .where(and(eq(apiKeys.userId, currentUser.id), eq(apiKeys.purpose, purpose))) + .limit(1); + + if (existing) { + await fetch(`${ENV.IMMICH_URL}/api/api-keys/${existing.immichKeyId}`, { + method: "DELETE", + headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }), + }); + } + + // Create new key in Immich + const immichRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys`, { + method: "POST", + headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }), + body: JSON.stringify({ name: config.keyName, permissions: config.permissions }), + }); + + if (!immichRes.ok) { + const err = await immichRes.json().catch(() => ({})); + return res.status(immichRes.status).json({ message: err.message ?? "Failed to create API key in Immich" }); + } + + const { secret, apiKey } = await immichRes.json(); + + await appDb + .insert(apiKeys) + .values({ userId: currentUser.id, purpose, keyName: config.keyName, secret, immichKeyId: apiKey.id }) + .onConflictDoUpdate({ + target: [apiKeys.userId, apiKeys.purpose], + set: { secret, immichKeyId: apiKey.id, keyName: config.keyName }, + }); + + return res.status(200).json({ success: true }); + } + + return res.status(405).json({ message: "Method not allowed" }); +} diff --git a/src/pages/api/settings/api-keys/index.ts b/src/pages/api/settings/api-keys/index.ts new file mode 100644 index 0000000..af8b783 --- /dev/null +++ b/src/pages/api/settings/api-keys/index.ts @@ -0,0 +1,28 @@ +import { appDb } from "@/db"; +import { apiKeys } from "@/db/schema"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { eq } from "drizzle-orm"; +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const currentUser = await getCurrentUser(req); + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const rows = await appDb + .select({ + id: apiKeys.id, + purpose: apiKeys.purpose, + keyName: apiKeys.keyName, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, currentUser.id)); + + return res.status(200).json(rows); +} diff --git a/src/pages/api/share-key/index.ts b/src/pages/api/share-key/index.ts new file mode 100644 index 0000000..8c55719 --- /dev/null +++ b/src/pages/api/share-key/index.ts @@ -0,0 +1,91 @@ +import { ENV } from "@/config/environment"; +import { appDb } from "@/db"; +import { apiKeys } from "@/db/schema"; +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { getUserHeaders } from "@/helpers/user.helper"; +import { and, eq } from "drizzle-orm"; +import { NextApiRequest, NextApiResponse } from "next"; + +const PURPOSE = "share"; +const KEY_NAME = "Power Tools Share Key"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const currentUser = await getCurrentUser(req); + if (!currentUser) { + return res.status(401).json({ message: "Unauthorized" }); + } + + if (req.method === "GET") { + const [row] = await appDb + .select({ keyName: apiKeys.keyName, createdAt: apiKeys.createdAt }) + .from(apiKeys) + .where(and(eq(apiKeys.userId, currentUser.id), eq(apiKeys.purpose, PURPOSE))) + .limit(1); + + return res.status(200).json({ + endpointConfigured: !!ENV.POWER_TOOLS_ENDPOINT_URL, + exists: !!row, + keyName: row?.keyName ?? null, + createdAt: row?.createdAt ?? null, + }); + } + + if (req.method === "POST") { + const immichRes = await fetch(`${ENV.IMMICH_URL}/api/api-keys`, { + method: "POST", + headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }), + body: JSON.stringify({ + name: KEY_NAME, + permissions: ["asset.view", "asset.download"], + }), + }); + + if (!immichRes.ok) { + const err = await immichRes.json().catch(() => ({})); + return res.status(immichRes.status).json({ message: err.message ?? "Failed to create API key in Immich" }); + } + + const { secret, apiKey } = await immichRes.json(); + + await appDb + .insert(apiKeys) + .values({ + userId: currentUser.id, + purpose: PURPOSE, + keyName: KEY_NAME, + secret, + immichKeyId: apiKey.id, + }) + .onConflictDoUpdate({ + target: [apiKeys.userId, apiKeys.purpose], + set: { secret, immichKeyId: apiKey.id, keyName: KEY_NAME }, + }); + + return res.status(200).json({ success: true }); + } + + if (req.method === "DELETE") { + const [row] = await appDb + .select() + .from(apiKeys) + .where(and(eq(apiKeys.userId, currentUser.id), eq(apiKeys.purpose, PURPOSE))) + .limit(1); + + if (!row) { + return res.status(404).json({ message: "No share key found" }); + } + + await fetch(`${ENV.IMMICH_URL}/api/api-keys/${row.immichKeyId}`, { + method: "DELETE", + headers: getUserHeaders(currentUser, { "Content-Type": "application/json" }), + }); + + await appDb + .delete(apiKeys) + .where(and(eq(apiKeys.userId, currentUser.id), eq(apiKeys.purpose, PURPOSE))); + + return res.status(200).json({ success: true }); + } + + return res.status(405).json({ message: "Method not allowed" }); +} diff --git a/src/pages/api/share-link/[token]/download.ts b/src/pages/api/share-link/[token]/download.ts index 9915a03..2fb3b20 100644 --- a/src/pages/api/share-link/[token]/download.ts +++ b/src/pages/api/share-link/[token]/download.ts @@ -1,9 +1,11 @@ import { ENV } from "@/config/environment"; +import { appDb } from "@/db"; +import { apiKeys } from "@/db/schema"; +import { and, eq } from "drizzle-orm"; import { JsonWebTokenError, verify } from "jsonwebtoken"; import { NextApiResponse } from "next"; import { NextApiRequest } from "next"; -import { getUserHeaders } from "@/helpers/user.helper"; interface ShareLinkFilters { personIds: string[]; @@ -23,20 +25,31 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(405).json({ message: "Method not allowed" }); } - const decoded = verify(token as string, ENV.JWT_SECRET); + const decoded = verify(token as string, ENV.JWT_SECRET) as { userId: string }; if (!decoded) { return res.status(401).json({ message: "Unauthorized" }); } + const [keyRow] = await appDb + .select({ secret: apiKeys.secret }) + .from(apiKeys) + .where(and(eq(apiKeys.userId, decoded.userId), eq(apiKeys.purpose, "share"))) + .limit(1); + + if (!keyRow) { + return res.status(403).json({ message: "Share key not configured. Please set up sharing in Power Tools." }); + } + const targetUrl = `${ENV.IMMICH_URL}/api/download/archive`; const response = await fetch(targetUrl, { method: 'POST', body: JSON.stringify({ assetIds }), - headers: getUserHeaders({ isUsingShareKey: true }, { + headers: { 'Content-Type': 'application/json', - }), + 'x-api-key': keyRow.secret, + }, }) if (!response.ok) { diff --git a/src/pages/api/share-link/[token]/index.ts b/src/pages/api/share-link/[token]/index.ts index d2a1d11..5c1a1ee 100644 --- a/src/pages/api/share-link/[token]/index.ts +++ b/src/pages/api/share-link/[token]/index.ts @@ -1,7 +1,6 @@ import { NextApiResponse } from "next"; import { ENV } from "@/config/environment"; -import { ShareLinkFilters } from "@/types/shareLink"; import { JsonWebTokenError, verify } from "jsonwebtoken"; import { NextApiRequest } from "next"; @@ -13,10 +12,6 @@ export default async function GET(req: NextApiRequest, res: NextApiResponse) { return res.status(404).json({ message: "Token not found" }); } - if (!ENV.IMMICH_SHARE_LINK_KEY) { - return res.status(401).json({ message: "Please check your link and try again. If you're the admin, check if you've enabled all the configurations in the Immich Power Tools in your environment variables" }); - } - const decoded = verify(token as string, ENV.JWT_SECRET); diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts index 139f7e9..b355354 100644 --- a/src/pages/api/share-link/generate.ts +++ b/src/pages/api/share-link/generate.ts @@ -19,15 +19,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "The JWT_SECRET is missing in the .env file. Please add it to the .env file for generating share links" }); } - if (!ENV.IMMICH_SHARE_LINK_KEY) { - return res.status(401).json({ message: "The IMMICH_SHARE_LINK_KEY is missing in the .env file. Please add it to the .env file for generating share links" }); - } - if (!ENV.POWER_TOOLS_ENDPOINT_URL) { return res.status(401).json({ message: "The POWER_TOOLS_ENDPOINT_URL is missing in the .env file. Please add it to the .env file for generating share links" }); } - const token = sign(filters, ENV.JWT_SECRET, expiresIn !== "never" ? { + const token = sign({ ...filters, userId: currentUser.id }, ENV.JWT_SECRET, expiresIn !== "never" ? { expiresIn: expiresIn } : {}); diff --git a/src/pages/settings/index.tsx b/src/pages/settings/index.tsx new file mode 100644 index 0000000..65ae191 --- /dev/null +++ b/src/pages/settings/index.tsx @@ -0,0 +1,133 @@ +import PageLayout from "@/components/layouts/PageLayout"; +import Header from "@/components/shared/Header"; +import { AlertDialog } from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { RefreshCw, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; + +interface ApiKey { + id: string; + purpose: string; + keyName: string; + createdAt: string; +} + +export default function SettingsPage() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [regenerating, setRegenerating] = useState(null); + + const fetchKeys = async () => { + setLoading(true); + const res = await fetch("/api/settings/api-keys"); + if (res.ok) { + const data = await res.json(); + setKeys(data); + } + setLoading(false); + }; + + useEffect(() => { + fetchKeys(); + }, []); + + const handleDelete = async (purpose: string) => { + setDeleting(purpose); + const res = await fetch(`/api/settings/api-keys/${purpose}`, { method: "DELETE" }); + if (res.ok) { + setKeys((prev) => prev.filter((k) => k.purpose !== purpose)); + toast.success("API key deleted"); + } else { + const data = await res.json(); + toast.error(data.message ?? "Failed to delete API key"); + } + setDeleting(null); + }; + + const handleRegenerate = async (purpose: string) => { + setRegenerating(purpose); + const res = await fetch(`/api/settings/api-keys/${purpose}`, { method: "PUT" }); + if (res.ok) { + await fetchKeys(); + toast.success("API key regenerated"); + } else { + const data = await res.json(); + toast.error(data.message ?? "Failed to regenerate API key"); + } + setRegenerating(null); + }; + + return ( + +
+
+ + + API Keys + + Managed API keys created by Power Tools in your Immich instance. + These are used internally for features that require unauthenticated access (e.g. share links). + + + + {loading && ( +

Loading...

+ )} + {!loading && keys.length === 0 && ( +

No API keys created yet.

+ )} + {!loading && keys.length > 0 && ( +
    + {keys.map((key) => ( +
  • +
    +
    + {key.keyName} + {key.purpose} +
    + + Created {new Date(key.createdAt).toLocaleDateString()} + +
    +
    + handleRegenerate(key.purpose)} + > + + + handleDelete(key.purpose)} + > + + +
    +
  • + ))} +
+ )} +
+
+
+ + ); +}