diff options
Diffstat (limited to 'packages/web/lib')
| -rw-r--r-- | packages/web/lib/api.ts | 81 | ||||
| -rw-r--r-- | packages/web/lib/auth.ts | 25 | ||||
| -rw-r--r-- | packages/web/lib/config.ts | 22 | ||||
| -rw-r--r-- | packages/web/lib/services/links.ts | 78 | ||||
| -rw-r--r-- | packages/web/lib/types/api/links.ts | 33 | ||||
| -rw-r--r-- | packages/web/lib/types/api/tags.ts | 6 | ||||
| -rw-r--r-- | packages/web/lib/types/next-auth.d.ts | 12 | ||||
| -rw-r--r-- | packages/web/lib/utils.ts | 6 |
8 files changed, 263 insertions, 0 deletions
diff --git a/packages/web/lib/api.ts b/packages/web/lib/api.ts new file mode 100644 index 00000000..56686cde --- /dev/null +++ b/packages/web/lib/api.ts @@ -0,0 +1,81 @@ +"use client"; + +import { ZodTypeAny, z } from "zod"; +import { + ZNewBookmarkedLinkRequest, + zGetLinksResponseSchema, +} from "./types/api/links"; + +import serverConfig from "./config"; + +const BASE_URL = `${serverConfig.api_url}/api/v1`; + +export type FetchError = { + status?: number; + message?: string; +}; + +type InputSchema<T> = T extends ZodTypeAny ? T : undefined; + +async function doRequest<T>( + path: string, + respSchema?: InputSchema<T>, + opts?: RequestInit, +): Promise< + | (InputSchema<T> extends ZodTypeAny + ? [z.infer<InputSchema<T>>, undefined] + : [undefined, undefined]) + | [undefined, FetchError] +> { + try { + const res = await fetch(`${BASE_URL}${path}`, opts); + if (!res.ok) { + return [ + undefined, + { status: res.status, message: await res.text() }, + ] as const; + } + if (!respSchema) { + return [undefined, undefined] as const; + } + + let parsed = respSchema.safeParse(await res.json()); + if (!parsed.success) { + return [ + undefined, + { message: `Failed to parse response: ${parsed.error.toString()}` }, + ] as const; + } + + return [parsed.data, undefined] as const; + } catch (error: any) { + return [ + undefined, + { message: `Failed to execute fetch request: ${error}` }, + ] as const; + } +} + +export default class APIClient { + static async getLinks() { + return await doRequest(`/links`, zGetLinksResponseSchema, { + next: { tags: ["links"] }, + }); + } + + static async bookmarkLink(url: string) { + const body: ZNewBookmarkedLinkRequest = { + url, + }; + return await doRequest(`/links`, undefined, { + method: "POST", + body: JSON.stringify(body), + }); + } + + static async unbookmarkLink(linkId: string) { + return await doRequest(`/links/${linkId}`, undefined, { + method: "DELETE", + }); + } +} diff --git a/packages/web/lib/auth.ts b/packages/web/lib/auth.ts new file mode 100644 index 00000000..cd6404de --- /dev/null +++ b/packages/web/lib/auth.ts @@ -0,0 +1,25 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import AuthentikProvider from "next-auth/providers/authentik"; +import serverConfig from "@/lib/config"; +import prisma from "@remember/db"; + +let providers = []; + +if (serverConfig.auth.authentik) { + providers.push(AuthentikProvider(serverConfig.auth.authentik)); +} + +export const authOptions: NextAuthOptions = { + // Configure one or more authentication providers + adapter: PrismaAdapter(prisma), + providers: providers, + callbacks: { + session({ session, user }) { + session.user = { ...user }; + return session; + }, + }, +}; + +export const authHandler = NextAuth(authOptions); diff --git a/packages/web/lib/config.ts b/packages/web/lib/config.ts new file mode 100644 index 00000000..ec042b54 --- /dev/null +++ b/packages/web/lib/config.ts @@ -0,0 +1,22 @@ +function buildAuthentikConfig() { + let { AUTHENTIK_ID, AUTHENTIK_SECRET, AUTHENTIK_ISSUER } = process.env; + + if (!AUTHENTIK_ID || !AUTHENTIK_SECRET || !AUTHENTIK_ISSUER) { + return undefined; + } + + return { + clientId: AUTHENTIK_ID, + clientSecret: AUTHENTIK_SECRET, + issuer: AUTHENTIK_ISSUER, + }; +} + +const serverConfig = { + api_url: process.env.API_URL || "http://localhost:3000", + auth: { + authentik: buildAuthentikConfig(), + }, +}; + +export default serverConfig; diff --git a/packages/web/lib/services/links.ts b/packages/web/lib/services/links.ts new file mode 100644 index 00000000..d273b118 --- /dev/null +++ b/packages/web/lib/services/links.ts @@ -0,0 +1,78 @@ +import { LinkCrawlerQueue } from "@remember/shared/queues"; +import prisma from "@remember/db"; +import { ZBookmarkedLink } from "@/lib/types/api/links"; + +const defaultLinkFields = { + id: true, + url: true, + createdAt: true, + details: { + select: { + title: true, + description: true, + imageUrl: true, + favicon: true, + }, + }, + tags: { + include: { + tag: true, + }, + }, +}; + +async function dummyPrismaReturnType() { + return await prisma.bookmarkedLink.findFirstOrThrow({ + select: defaultLinkFields, + }); +} + +function toZodSchema( + link: Awaited<ReturnType<typeof dummyPrismaReturnType>>, +): ZBookmarkedLink { + return { + id: link.id, + url: link.url, + createdAt: link.createdAt, + details: link.details, + tags: link.tags.map((t) => t.tag), + }; +} + +export async function unbookmarkLink(linkId: string, userId: string) { + await prisma.bookmarkedLink.delete({ + where: { + id: linkId, + userId, + }, + }); +} + +export async function bookmarkLink(url: string, userId: string) { + const link = await prisma.bookmarkedLink.create({ + data: { + url, + userId, + }, + select: defaultLinkFields, + }); + + // Enqueue crawling request + await LinkCrawlerQueue.add("crawl", { + linkId: link.id, + url: link.url, + }); + + return toZodSchema(link); +} + +export async function getLinks(userId: string) { + return ( + await prisma.bookmarkedLink.findMany({ + where: { + userId, + }, + select: defaultLinkFields, + }) + ).map(toZodSchema); +} diff --git a/packages/web/lib/types/api/links.ts b/packages/web/lib/types/api/links.ts new file mode 100644 index 00000000..f84445f6 --- /dev/null +++ b/packages/web/lib/types/api/links.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { zBookmarkTagSchema } from "@/lib/types/api/tags"; + +export const zBookmarkedLinkSchema = z.object({ + id: z.string(), + url: z.string().url(), + createdAt: z.coerce.date(), + + details: z + .object({ + title: z.string().nullish(), + description: z.string().nullish(), + imageUrl: z.string().url().nullish(), + favicon: z.string().url().nullish(), + }) + .nullish(), + tags: z.array(zBookmarkTagSchema), +}); +export type ZBookmarkedLink = z.infer<typeof zBookmarkedLinkSchema>; + +// POST /v1/links +export const zNewBookmarkedLinkRequestSchema = zBookmarkedLinkSchema.pick({ + url: true, +}); +export type ZNewBookmarkedLinkRequest = z.infer< + typeof zNewBookmarkedLinkRequestSchema +>; + +// GET /v1/links +export const zGetLinksResponseSchema = z.object({ + links: z.array(zBookmarkedLinkSchema), +}); +export type ZGetLinksResponse = z.infer<typeof zGetLinksResponseSchema>; diff --git a/packages/web/lib/types/api/tags.ts b/packages/web/lib/types/api/tags.ts new file mode 100644 index 00000000..f2d2bc18 --- /dev/null +++ b/packages/web/lib/types/api/tags.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const zBookmarkTagSchema = z.object({ + id: z.string(), + name: z.string(), +}); diff --git a/packages/web/lib/types/next-auth.d.ts b/packages/web/lib/types/next-auth.d.ts new file mode 100644 index 00000000..cd47dfce --- /dev/null +++ b/packages/web/lib/types/next-auth.d.ts @@ -0,0 +1,12 @@ +import { DefaultSession } from "next-auth"; + +declare module "next-auth" { + /** + * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context + */ + export interface Session { + user: { + id: string; + } & DefaultSession["user"]; + } +} diff --git a/packages/web/lib/utils.ts b/packages/web/lib/utils.ts new file mode 100644 index 00000000..365058ce --- /dev/null +++ b/packages/web/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} |
