diff --git a/bun.lockb b/bun.lockb index 422f823..9d2a89a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 81dac4b..03caf4c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "cmdk": "1.0.0", "date-fns": "^3.6.0", "drizzle-orm": "^0.33.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.428.0", "next": "14.2.5", "next-themes": "^0.3.0", @@ -56,6 +57,7 @@ "@semantic-release/github": "^10.1.7", "@semantic-release/npm": "^12.0.1", "@semantic-release/release-notes-generator": "^14.0.1", + "@types/jsonwebtoken": "^9.0.6", "@types/node": "^20", "@types/pg": "^8.11.6", "@types/react": "^18", diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..b2d8ce2 --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,88 @@ +import Link from "next/link" + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useState } from "react" +import { loginUser } from "@/handlers/api/user.handler" + +export const description = + "A login form with email and password. There's an option to login with Google and a link to sign up if you don't have an account." + +export function LoginForm() { + + const [formData, setFormData] = useState({ + email: "", + password: "", + }) + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.id]: e.target.value }) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + loginUser(formData.email, formData.password).then((user) => { + console.log(user) + }).catch((error) => { + setErrorMessage(error.message) + }).finally(() => { + setLoading(false) + }) + } + + + return ( + + + Login + + Enter your email below to login to your account + + + +
+
+ + +
+
+
+ + + Forgot your password? + +
+ +
+ + +
+ +
+
+ ) +} diff --git a/src/components/layouts/RootLayout.tsx b/src/components/layouts/RootLayout.tsx index bf6e093..0055795 100644 --- a/src/components/layouts/RootLayout.tsx +++ b/src/components/layouts/RootLayout.tsx @@ -8,6 +8,7 @@ import ErrorBlock from "../shared/ErrorBlock"; import Loader from "../ui/loader"; import { ArrowRight, TriangleAlert } from "lucide-react"; import Link from "next/link"; +import { LoginForm } from "../auth/LoginForm"; type RootLayoutProps = { children: ReactNode; @@ -25,7 +26,8 @@ export default function RootLayout({ children }: RootLayoutProps) { return getMe() .then(setUser) .catch((error) => { - setErrorMessage(error); + if (error.status === 403) return setUser(null); + return setErrorMessage(error); }) .finally(() => setLoading(false)); }; @@ -36,6 +38,7 @@ export default function RootLayout({ children }: RootLayoutProps) { if (loading) return ; if (errorMessage) + return (
); - if (!user) return null; + + if (!user) return ; return ( diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 3c9063a..c0ac4db 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -76,12 +76,12 @@ CardFooter.displayName = "CardFooter" interface CardProps { title?: string description?: string - + className?: string children: React.ReactNode } -const Card = ({ children, title, description }: CardProps) => { +const Card = ({ children, title, description, className }: CardProps) => { return ( - + {!!(title || description) && ( {title && {title}} diff --git a/src/config/app.config.ts b/src/config/app.config.ts new file mode 100644 index 0000000..749a616 --- /dev/null +++ b/src/config/app.config.ts @@ -0,0 +1,6 @@ +import { ENV } from "./environment"; + +export const appConfig = { + sessionCookieName: 'power-tools-session', + jwtToken: ENV.JWT_SECRET || "jwt-secret", // This is a fallback value, it should be replaced with a proper secret +} \ No newline at end of file diff --git a/src/config/environment.ts b/src/config/environment.ts index f0972b5..3062eda 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -3,6 +3,7 @@ export const ENV = { EXTERNAL_IMMICH_URL: (process.env.EXTERNAL_IMMICH_URL || process.env.IMMICH_URL) as string, IMMICH_API_KEY: process.env.IMMICH_API_KEY as string, DATABASE_URL: (process.env.DATABASE_URL || `postgresql://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${(process.env.DB_HOST || 'immich_postgres')}:${(process.env.DB_PORT || '5432')}/${process.env.DB_DATABASE_NAME}`), + JWT_SECRET: process.env.JWT_SECRET as string, }; diff --git a/src/config/routes.ts b/src/config/routes.ts index 2d59e96..2f55c49 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -1,7 +1,9 @@ export const BASE_API_ENDPOINT = '/api'; export const BASE_PROXY_ENDPOINT = BASE_API_ENDPOINT + '/immich-proxy'; -export const GET_ME_PATH = BASE_PROXY_ENDPOINT + "/users/me"; +export const GET_ME_PATH = BASE_API_ENDPOINT + "/users/me"; +export const LOGIN_PATH = BASE_API_ENDPOINT + "/users/login"; + export const LIST_PEOPLE_PATH = BASE_API_ENDPOINT + "/people/list"; export const SEARCH_PEOPLE_PATH = BASE_PROXY_ENDPOINT + "/search/person"; diff --git a/src/handlers/api/user.handler.ts b/src/handlers/api/user.handler.ts index 534de92..32c8c55 100644 --- a/src/handlers/api/user.handler.ts +++ b/src/handlers/api/user.handler.ts @@ -1,6 +1,10 @@ -import { GET_ME_PATH } from "@/config/routes"; +import { GET_ME_PATH, LOGIN_PATH } from "@/config/routes"; import API from "@/lib/api"; export const getMe = async () => { return API.get(GET_ME_PATH); +} + +export const loginUser = async (email: string, password: string) => { + return API.post(LOGIN_PATH, { email, password }); } \ No newline at end of file diff --git a/src/handlers/serverUtils/user.utils.ts b/src/handlers/serverUtils/user.utils.ts index fd46860..a47eed2 100644 --- a/src/handlers/serverUtils/user.utils.ts +++ b/src/handlers/serverUtils/user.utils.ts @@ -1,6 +1,13 @@ +import { appConfig } from "@/config/app.config" import { ENV } from "@/config/environment" +import { getCookie } from "@/lib/cookie" +import { decodeJWT } from "@/lib/jwt" +import { NextApiRequest } from "next" + + +export const getCurrentUserFromAPIKey = () => { + if (!ENV.IMMICH_URL || !ENV.IMMICH_API_KEY) return null; -export const getCurrentUser = () => { return fetch(ENV.IMMICH_URL + "/api/users/me", { headers: { 'x-api-key': ENV.IMMICH_API_KEY, @@ -10,6 +17,50 @@ export const getCurrentUser = () => { if (res.ok) { return res.json() } - throw new Error("Failed to fetch user") + return null }) +} + +export const getCurrentUserFromAccessToken = (token: string) => { + return fetch(ENV.IMMICH_URL + "/api/users/me", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + }).then((res) => { + if (res.ok) { + return res.json() + } + return null + }) +} + +export const getCurrentUser = async (req: NextApiRequest) => { + const session = getCookie(req, appConfig.sessionCookieName) + + if (session) { + const user = await getCurrentUserFromAccessToken(session) + return { + ...user, + accessToken: session, + } + } + + return getCurrentUserFromAPIKey() +} + +export const loginUser = async (email: string, password: string) => { + const res = await fetch(ENV.IMMICH_URL + "/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }) + + if (res.ok) { + return res.json() + } + + return null } \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index bb00bf0..0483d40 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,23 +7,15 @@ const handleErrors = (error: AxiosError | any) => { if (error.code === "ERR_BAD_RESPONSE") { return Promise.reject({ error: "Unable to process this request, please try again later", - message: error.code + message: error.code, }); } let errorObject = {} if (error.response) { - const { status } = error.response - if (status === 422) { - errorObject = { - ...error.response.data, - message: 'Unable to process this request, please try again later', - } - } else { - errorObject = { - ...error.response.data, - } - } - return Promise.reject(errorObject) + return Promise.reject({ + ...error.response.data, + status: error.response.status, + }) } errorObject = { message: 'Network Error. Please check your connection and try again!', diff --git a/src/lib/cookie.ts b/src/lib/cookie.ts new file mode 100644 index 0000000..2174af5 --- /dev/null +++ b/src/lib/cookie.ts @@ -0,0 +1,30 @@ +import type { CookieSerializeOptions } from 'cookie' +import cookie from 'cookie' +import type { IncomingMessage } from 'http' +import type { NextApiRequest } from 'next' + +export const getCookie = ( + req: NextApiRequest | IncomingMessage, + name: string +) => { + const cookieData = cookie.parse(req.headers.cookie || '') + if (cookieData && cookieData[name]) { + return cookieData[name] + } + return null +} + +export const serializeCookie = ( + name: string, + value: string, + options: CookieSerializeOptions = {} +) => { + return cookie.serialize(name, String(value), { + httpOnly: true, + maxAge: 60 * 60 * 24 * 7, + sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + ...options, + }) +} \ No newline at end of file diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..3dd0375 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,16 @@ +import jwt from 'jsonwebtoken' + +export const decodeJWT = (token: string, key: string): any => { + try { + const payload = jwt.verify(token, key, { + ignoreExpiration: true + }) + return payload + } catch (error) { + return null + } +} + +export const encodeJWT = (payload: any, key: string): string => { + return jwt.sign(payload, key) +} diff --git a/src/pages/api/albums/potential-albums-assets.ts b/src/pages/api/albums/potential-albums-assets.ts index 4ab957f..555919a 100644 --- a/src/pages/api/albums/potential-albums-assets.ts +++ b/src/pages/api/albums/potential-albums-assets.ts @@ -46,7 +46,7 @@ export default async function handler( res: NextApiResponse ) { try { - const currentUser = await getCurrentUser() + const currentUser = await getCurrentUser(req) const { startDate } = req.query as { startDate: string }; const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id)); return res.status(200).json(rows); diff --git a/src/pages/api/albums/potential-albums-dates.ts b/src/pages/api/albums/potential-albums-dates.ts index 0108b4c..45af737 100644 --- a/src/pages/api/albums/potential-albums-dates.ts +++ b/src/pages/api/albums/potential-albums-dates.ts @@ -28,7 +28,7 @@ export default async function handler( res: NextApiResponse ) { try { - const currentUser = await getCurrentUser() + const currentUser = await getCurrentUser(req) const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id)); return res.status(200).json(rows); } catch (error: any) { diff --git a/src/pages/api/analytics/exif/[property].ts b/src/pages/api/analytics/exif/[property].ts index cb30ba3..13e99ed 100644 --- a/src/pages/api/analytics/exif/[property].ts +++ b/src/pages/api/analytics/exif/[property].ts @@ -34,7 +34,7 @@ export default async function handler( } try { - const currentUser = await getCurrentUser(); + const currentUser = await getCurrentUser(req); const dataFromDB = await db.select({ value: count(), label: column, diff --git a/src/pages/api/assets/missing-location-assets.ts b/src/pages/api/assets/missing-location-assets.ts index 1594cf4..56833b2 100644 --- a/src/pages/api/assets/missing-location-assets.ts +++ b/src/pages/api/assets/missing-location-assets.ts @@ -15,7 +15,7 @@ export default async function handler( startDate: string; }; - const currentUser = await getCurrentUser(); + const currentUser = await getCurrentUser(req); if (!startDate) { return res.status(400).json({ diff --git a/src/pages/api/assets/missing-location-dates.ts b/src/pages/api/assets/missing-location-dates.ts index 1680d7f..01eea33 100644 --- a/src/pages/api/assets/missing-location-dates.ts +++ b/src/pages/api/assets/missing-location-dates.ts @@ -11,7 +11,7 @@ export default async function handler( res: NextApiResponse ) { try { - const currentUser = await getCurrentUser(); + const currentUser = await getCurrentUser(req); const rows = await db .select({ asset_count: desc(count(assets.id)), diff --git a/src/pages/api/immich-proxy/thumbnail/[id].ts b/src/pages/api/immich-proxy/thumbnail/[id].ts index e4042fe..5fb0edb 100644 --- a/src/pages/api/immich-proxy/thumbnail/[id].ts +++ b/src/pages/api/immich-proxy/thumbnail/[id].ts @@ -1,6 +1,7 @@ // pages/api/proxy.js import { ENV } from '@/config/environment'; +import { getCurrentUser } from '@/handlers/serverUtils/user.utils'; import { NextApiRequest, NextApiResponse } from 'next' export const config = { @@ -17,13 +18,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const { id } = req.query; const targetUrl = `${ENV.IMMICH_URL}/api/people/${id}/thumbnail`; + const currentUser = await getCurrentUser(req); + + if (!currentUser) { + return res.status(403).json({ message: 'Forbidden' }) + } + + console.log('currentUser.accessToken:', currentUser) + try { // Forward the request to the target API const response = await fetch(targetUrl, { method: 'GET', headers: { 'Content-Type': 'application/octet-stream', - 'x-api-key': ENV.IMMICH_API_KEY, + Authorization: `Bearer ${currentUser.accessToken}`, }, }) diff --git a/src/pages/api/people/[id]/similar-faces.ts b/src/pages/api/people/[id]/similar-faces.ts index ff3e679..599b646 100644 --- a/src/pages/api/people/[id]/similar-faces.ts +++ b/src/pages/api/people/[id]/similar-faces.ts @@ -34,7 +34,7 @@ export default async function handler( sortOrder = "desc", } = req.query as any as IQuery; - const currentUser = await getCurrentUser(); + const currentUser = await getCurrentUser(req); const personRecords = await db .select() .from(person) diff --git a/src/pages/api/people/list.ts b/src/pages/api/people/list.ts index d21b504..4885d5b 100644 --- a/src/pages/api/people/list.ts +++ b/src/pages/api/people/list.ts @@ -44,7 +44,7 @@ export default async function handler( sortOrder = "desc", } = req.query as any as IQuery; - const currentUser = await getCurrentUser(); + const currentUser = await getCurrentUser(req); const maximumAssetCount = !maxValue || maxValue <= 0 ? 1000000 : maxValue; const whereClause = and( diff --git a/src/pages/api/users/login.ts b/src/pages/api/users/login.ts new file mode 100644 index 0000000..9410e7d --- /dev/null +++ b/src/pages/api/users/login.ts @@ -0,0 +1,36 @@ +// pages/api/proxy.js + +import { appConfig } from "@/config/app.config"; +import { getCurrentUser, loginUser } from "@/handlers/serverUtils/user.utils"; +import { serializeCookie } from "@/lib/cookie"; +import { NextApiRequest, NextApiResponse } from "next"; + +export const config = { + api: { + bodyParser: true, + }, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + return res.status(405).json({ message: "Method Not Allowed" }); + } + + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ message: "Email and password are required" }); + } + + const user = await loginUser(email, password); + if (user) { + res.setHeader("Set-Cookie", serializeCookie(appConfig.sessionCookieName, user.accessToken)); + return res.status(200).json(user); + } + else { + return res.status(403).json({ message: "Invalid User Credentials" }); + } + +} diff --git a/src/pages/api/users/me.ts b/src/pages/api/users/me.ts new file mode 100644 index 0000000..38b8496 --- /dev/null +++ b/src/pages/api/users/me.ts @@ -0,0 +1,25 @@ +// pages/api/proxy.js + +import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; +import { NextApiRequest, NextApiResponse } from "next"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "GET") + + return res.status(405).json({ message: "Method Not Allowed" }); + + const user = getCurrentUser(req); + + if (user) return res.status(200).json(user); + + return res.status(403).json({ message: "Forbidden" }); +}