CLI hello-world works and talks to TRPC! And starting to clean things up

This commit is contained in:
Ryan Pandya 2024-11-14 18:27:22 -08:00
parent 0fd8ce7189
commit ea2a9da1e0
16 changed files with 614 additions and 582 deletions

View File

@ -15,8 +15,9 @@ helloWorldCmd
.description("does something specific I guess") .description("does something specific I guess")
.action(async () => { .action(async () => {
const api = getAPIClient(); const api = getAPIClient();
const whoami = await api.users.whoami.query();
try { try {
console.dir(api); console.log("Hello " + whoami.name);
} catch (error) { } catch (error) {
printErrorMessageWithReason( printErrorMessageWithReason(
"Something went horribly wrong", "Something went horribly wrong",

View File

@ -5,9 +5,11 @@ import type { AppRouter } from "@lifetracker/trpc/routers/_app";
export function getAPIClient() { export function getAPIClient() {
const globals = getGlobalOptions(); const globals = getGlobalOptions();
return createTRPCProxyClient<AppRouter>({ return createTRPCProxyClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
transformer: superjson,
url: `${globals.serverAddr}/api/trpc`, url: `${globals.serverAddr}/api/trpc`,
maxURLLength: 14000, maxURLLength: 14000,
headers() { headers() {

View File

@ -1,326 +0,0 @@
"use client";
import { ActionButton } from "@/components/ui/action-button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Save, Trash2 } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { buildImagePrompt, buildTextPrompt } from "@hoarder/shared/prompts";
import {
zNewPromptSchema,
ZPrompt,
zUpdatePromptSchema,
} from "@hoarder/shared/types/prompts";
export function PromptEditor() {
const apiUtils = api.useUtils();
const form = useForm<z.infer<typeof zNewPromptSchema>>({
resolver: zodResolver(zNewPromptSchema),
defaultValues: {
text: "",
appliesTo: "all",
},
});
const { mutateAsync: createPrompt, isPending: isCreating } =
api.prompts.create.useMutation({
onSuccess: () => {
toast({
description: "Prompt has been created!",
});
apiUtils.prompts.list.invalidate();
},
});
return (
<Form {...form}>
<form
className="flex gap-2"
onSubmit={form.handleSubmit(async (value) => {
await createPrompt(value);
form.resetField("text");
})}
>
<FormField
control={form.control}
name="text"
render={({ field }) => {
return (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder="Add a custom prompt"
type="text"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="appliesTo"
render={({ field }) => {
return (
<FormItem className="flex-0">
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Applies To" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="images">Images</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<ActionButton
type="submit"
loading={isCreating}
variant="default"
className="items-center"
>
<Plus className="mr-2 size-4" />
Add
</ActionButton>
</form>
</Form>
);
}
export function PromptRow({ prompt }: { prompt: ZPrompt }) {
const apiUtils = api.useUtils();
const { mutateAsync: updatePrompt, isPending: isUpdating } =
api.prompts.update.useMutation({
onSuccess: () => {
toast({
description: "Prompt has been updated!",
});
apiUtils.prompts.list.invalidate();
},
});
const { mutate: deletePrompt, isPending: isDeleting } =
api.prompts.delete.useMutation({
onSuccess: () => {
toast({
description: "Prompt has been deleted!",
});
apiUtils.prompts.list.invalidate();
},
});
const form = useForm<z.infer<typeof zUpdatePromptSchema>>({
resolver: zodResolver(zUpdatePromptSchema),
defaultValues: {
promptId: prompt.id,
text: prompt.text,
appliesTo: prompt.appliesTo,
},
});
return (
<Form {...form}>
<form
className="flex gap-2"
onSubmit={form.handleSubmit(async (value) => {
await updatePrompt(value);
})}
>
<FormField
control={form.control}
name="promptId"
render={({ field }) => {
return (
<FormItem className="hidden">
<FormControl>
<Input
placeholder="Add a custom prompt"
type="hidden"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="text"
render={({ field }) => {
return (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder="Add a custom prompt"
type="text"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="appliesTo"
render={({ field }) => {
return (
<FormItem className="flex-0">
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Applies To" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="images">Images</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<ActionButton
loading={isUpdating}
variant="secondary"
type="submit"
className="items-center"
>
<Save className="mr-2 size-4" />
Save
</ActionButton>
<ActionButton
loading={isDeleting}
variant="destructive"
onClick={() => deletePrompt({ promptId: prompt.id })}
className="items-center"
type="button"
>
<Trash2 className="mr-2 size-4" />
Delete
</ActionButton>
</form>
</Form>
);
}
export function TaggingRules() {
const { data: prompts, isLoading } = api.prompts.list.useQuery();
return (
<div className="mt-2 flex flex-col gap-2">
<div className="w-full text-xl font-medium sm:w-1/3">Tagging Rules</div>
<p className="mb-1 text-xs italic text-muted-foreground">
Prompts that you add here will be included as rules to the model during
tag generation. You can view the final prompts in the prompt preview
section.
</p>
{isLoading && <FullPageSpinner />}
{prompts && prompts.length == 0 && (
<p className="rounded-md bg-muted p-2 text-sm text-muted-foreground">
You don&apos;t have any custom prompts yet.
</p>
)}
{prompts &&
prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)}
<PromptEditor />
</div>
);
}
export function PromptDemo() {
const { data: prompts } = api.prompts.list.useQuery();
const clientConfig = useClientConfig();
return (
<div className="flex flex-col gap-2">
<div className="mb-4 w-full text-xl font-medium sm:w-1/3">
Prompt Preview
</div>
<p>Text Prompt</p>
<code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
{buildTextPrompt(
clientConfig.inference.inferredTagLang,
(prompts ?? [])
.filter((p) => p.appliesTo == "text" || p.appliesTo == "all")
.map((p) => p.text),
"\n<CONTENT_HERE>\n",
/* context length */ 1024 /* The value here doesn't matter */,
).trim()}
</code>
<p>Image Prompt</p>
<code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
{buildImagePrompt(
clientConfig.inference.inferredTagLang,
(prompts ?? [])
.filter((p) => p.appliesTo == "images" || p.appliesTo == "all")
.map((p) => p.text),
).trim()}
</code>
</div>
);
}
export default function AISettings() {
return (
<>
<div className="rounded-md border bg-background p-4">
<div className="mb-2 flex flex-col gap-3">
<div className="w-full text-2xl font-medium sm:w-1/3">
AI Settings
</div>
<TaggingRules />
</div>
</div>
<div className="mt-4 rounded-md border bg-background p-4">
<PromptDemo />
</div>
</>
);
}

View File

@ -16,7 +16,7 @@ import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zChangePasswordSchema } from "@hoarder/shared/types/users"; import { zChangePasswordSchema } from "@lifetracker/shared/types/users";
export function ChangePassword() { export function ChangePassword() {
const form = useForm<z.infer<typeof zChangePasswordSchema>>({ const form = useForm<z.infer<typeof zChangePasswordSchema>>({

View File

@ -48,223 +48,8 @@ export function ExportButton() {
export function ImportExportRow() { export function ImportExportRow() {
const router = useRouter(); const router = useRouter();
const [importProgress, setImportProgress] = useState<{
done: number;
total: number;
} | null>(null);
const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook();
const { mutateAsync: updateBookmark } = useUpdateBookmark();
const { mutateAsync: createList } = useCreateBookmarkList();
const { mutateAsync: addToList } = useAddBookmarkToList();
const { mutateAsync: updateTags } = useUpdateBookmarkTags();
const { mutateAsync: parseAndCreateBookmark } = useMutation({
mutationFn: async (toImport: {
bookmark: ParsedBookmark;
listId: string;
}) => {
const bookmark = toImport.bookmark;
if (bookmark.content === undefined) {
throw new Error("Content is undefined");
}
const created = await createBookmark(
bookmark.content.type === BookmarkTypes.LINK
? {
type: BookmarkTypes.LINK,
url: bookmark.content.url,
}
: {
type: BookmarkTypes.TEXT,
text: bookmark.content.text,
},
);
await Promise.all([
// Update title and createdAt if they're set
bookmark.title.length > 0 || bookmark.addDate
? updateBookmark({
bookmarkId: created.id,
title: bookmark.title,
createdAt: bookmark.addDate
? new Date(bookmark.addDate * 1000)
: undefined,
note: bookmark.notes,
}).catch(() => {
/* empty */
})
: undefined,
// Add to import list
addToList({
bookmarkId: created.id,
listId: toImport.listId,
}).catch((e) => {
if (
e instanceof TRPCClientError &&
e.message.includes("already in the list")
) {
/* empty */
} else {
throw e;
}
}),
// Update tags
bookmark.tags.length > 0
? updateTags({
bookmarkId: created.id,
attach: bookmark.tags.map((t) => ({ tagName: t })),
detach: [],
})
: undefined,
]);
return created;
},
});
const { mutateAsync: runUploadBookmarkFile } = useMutation({
mutationFn: async ({
file,
source,
}: {
file: File;
source: "html" | "pocket" | "omnivore" | "hoarder";
}) => {
if (source === "html") {
return await parseNetscapeBookmarkFile(file);
} else if (source === "pocket") {
return await parsePocketBookmarkFile(file);
} else if (source === "hoarder") {
return await parseHoarderBookmarkFile(file);
} else if (source === "omnivore") {
return await parseOmnivoreBookmarkFile(file);
} else {
throw new Error("Unknown source");
}
},
onSuccess: async (resp) => {
const importList = await createList({
name: `Imported Bookmarks`,
icon: "⬆️",
});
setImportProgress({ done: 0, total: resp.length });
const successes = [];
const failed = [];
const alreadyExisted = [];
// Do the imports one by one
for (const parsedBookmark of resp) {
try {
const result = await parseAndCreateBookmark({
bookmark: parsedBookmark,
listId: importList.id,
});
if (result.alreadyExists) {
alreadyExisted.push(parsedBookmark);
} else {
successes.push(parsedBookmark);
}
} catch (e) {
failed.push(parsedBookmark);
}
setImportProgress((prev) => ({
done: (prev?.done ?? 0) + 1,
total: resp.length,
}));
}
if (successes.length > 0 || alreadyExisted.length > 0) {
toast({
description: `Imported ${successes.length} bookmarks and skipped ${alreadyExisted.length} bookmarks that already existed`,
variant: "default",
});
}
if (failed.length > 0) {
toast({
description: `Failed to import ${failed.length} bookmarks`,
variant: "destructive",
});
}
router.push(`/dashboard/lists/${importList.id}`);
},
onError: (error) => {
toast({
description: error.message,
variant: "destructive",
});
},
});
return ( return (
<div className="flex flex-col gap-3"> <div>TBD!</div>
<div className="flex flex-row flex-wrap gap-2">
<FilePickerButton
loading={false}
accept=".html"
multiple={false}
className="flex items-center gap-2"
onFileSelect={(file) =>
runUploadBookmarkFile({ file, source: "html" })
}
>
<Upload />
<p>Import Bookmarks from HTML file</p>
</FilePickerButton>
<FilePickerButton
loading={false}
accept=".csv"
multiple={false}
className="flex items-center gap-2"
onFileSelect={(file) =>
runUploadBookmarkFile({ file, source: "pocket" })
}
>
<Upload />
<p>Import Bookmarks from Pocket export</p>
</FilePickerButton>
<FilePickerButton
loading={false}
accept=".json"
multiple={false}
className="flex items-center gap-2"
onFileSelect={(file) =>
runUploadBookmarkFile({ file, source: "omnivore" })
}
>
<Upload />
<p>Import Bookmarks from Omnivore export</p>
</FilePickerButton>
<FilePickerButton
loading={false}
accept=".json"
multiple={false}
className="flex items-center gap-2"
onFileSelect={(file) =>
runUploadBookmarkFile({ file, source: "hoarder" })
}
>
<Upload />
<p>Import Bookmarks from Hoarder export</p>
</FilePickerButton>
<ExportButton />
</div>
{importProgress && (
<div className="flex flex-col gap-2">
<p className="shrink-0 text-sm">
Processed {importProgress.done} of {importProgress.total} bookmarks
</p>
<div className="w-full">
<Progress
value={(importProgress.done * 100) / importProgress.total}
/>
</div>
</div>
)}
</div>
); );
} }

View File

@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import SidebarItem from "@/components/shared/sidebar/SidebarItem"; import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { getServerAuthSession } from "@/server/auth"; import { getServerAuthSession } from "@/server/auth";
import serverConfig from "@hoarder/shared/config"; import serverConfig from "@lifetracker/shared/config";
import { settingsSidebarItems } from "./items"; import { settingsSidebarItems } from "./items";

View File

@ -3,8 +3,6 @@ import {
ArrowLeft, ArrowLeft,
Download, Download,
KeyRound, KeyRound,
Rss,
Sparkles,
User, User,
} from "lucide-react"; } from "lucide-react";
@ -16,23 +14,13 @@ export const settingsSidebarItems: {
{ {
name: "Back To App", name: "Back To App",
icon: <ArrowLeft size={18} />, icon: <ArrowLeft size={18} />,
path: "/dashboard/bookmarks", path: "/dashboard/today",
}, },
{ {
name: "User Info", name: "User Info",
icon: <User size={18} />, icon: <User size={18} />,
path: "/settings/info", path: "/settings/info",
}, },
{
name: "AI Settings",
icon: <Sparkles size={18} />,
path: "/settings/ai",
},
{
name: "RSS Subscriptions",
icon: <Rss size={18} />,
path: "/settings/feeds",
},
{ {
name: "Import / Export", name: "Import / Export",
icon: <Download size={18} />, icon: <Download size={18} />,

View File

@ -17,6 +17,11 @@ export async function createContextFromRequest(req: Request) {
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
const token = authorizationHeader.split(" ")[1]; const token = authorizationHeader.split(" ")[1];
try { try {
console.log("\n\nEeeeeeentering hell\n\n");
console.log(await authenticateApiKey(token));
console.log("\n\n\n\n");
const user = await authenticateApiKey(token); const user = await authenticateApiKey(token);
return { return {
user, user,

View File

@ -8,9 +8,7 @@ import path from "path";
import dbConfig from "./drizzle.config"; import dbConfig from "./drizzle.config";
const sqlite = new Database(dbConfig.dbCredentials.url); const sqlite = new Database(dbConfig.dbCredentials.url);
export const db = drizzle(sqlite, { schema, logger: true });
console.log(dbConfig.dbCredentials.url);
export const db = drizzle(sqlite, { schema });
export function getInMemoryDB(runMigrations: boolean) { export function getInMemoryDB(runMigrations: boolean) {
const mem = new Database(":memory:"); const mem = new Database(":memory:");

View File

@ -0,0 +1,4 @@
CREATE TABLE `config` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);

View File

@ -0,0 +1,423 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bcdf4680-869f-4b4a-9690-1bd45a196387",
"prevId": "67edcacc-ad95-4e34-b368-4beeda535072",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"apiKey": {
"name": "apiKey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyId": {
"name": "keyId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyHash": {
"name": "keyHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"apiKey_keyId_unique": {
"name": "apiKey_keyId_unique",
"columns": [
"keyId"
],
"isUnique": true
},
"apiKey_name_userId_unique": {
"name": "apiKey_name_userId_unique",
"columns": [
"name",
"userId"
],
"isUnique": true
}
},
"foreignKeys": {
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"config": {
"name": "config",
"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": {}
},
"day": {
"name": "day",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"day_date_unique": {
"name": "day_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'user'"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -29,6 +29,13 @@
"when": 1731542202120, "when": 1731542202120,
"tag": "0003_complex_ares", "tag": "0003_complex_ares",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1731637559763,
"tag": "0004_sad_sally_floyd",
"breakpoints": true
} }
] ]
} }

View File

@ -104,3 +104,17 @@ export const days = sqliteTable("day", {
date: text("date").notNull().unique(), date: text("date").notNull().unique(),
comment: text("comment").notNull(), comment: text("comment").notNull(),
}); });
export const config = sqliteTable("config", {
key: text("key").notNull().primaryKey(),
value: text("value").notNull(),
});
// Relations
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
user: one(users, {
fields: [apiKeys.userId],
references: [users.id],
}),
}));

View File

@ -54,7 +54,21 @@ function parseApiKey(plain: string) {
} }
export async function authenticateApiKey(key: string) { export async function authenticateApiKey(key: string) {
const { keyId, keySecret } = parseApiKey(key); const { keyId, keySecret } = parseApiKey(key);
console.log("\n\nWELCOME TO HELL\n\n");
console.log(parseApiKey(key));
// Console.log all rows in the apiKeys table
console.log(await db.query.apiKeys.findMany());
console.log(await db.query.apiKeys.findFirst({
where: (k, { eq }) => eq(k.keyId, keyId),
with: {
user: true,
},
}));
console.log("\n\n\n\n");
const apiKey = await db.query.apiKeys.findFirst({ const apiKey = await db.query.apiKeys.findFirst({
where: (k, { eq }) => eq(k.keyId, keyId), where: (k, { eq }) => eq(k.keyId, keyId),
with: { with: {

View File

@ -1,8 +1,11 @@
import { apiKeys } from "@lifetracker/db/schema";
import { router } from "../index"; import { router } from "../index";
import { usersAppRouter } from "./users"; import { usersAppRouter } from "./users";
import { apiKeysAppRouter } from "./apiKeys";
export const appRouter = router({ export const appRouter = router({
users: usersAppRouter users: usersAppRouter,
apiKeys: apiKeysAppRouter,
}); });
// export type definition of API // export type definition of API
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,114 @@
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { apiKeys } from "@lifetracker/db/schema";
import serverConfig from "@lifetracker/shared/config";
import {
authenticateApiKey,
generateApiKey,
logAuthenticationError,
validatePassword,
} from "../auth";
import { authedProcedure, publicProcedure, router } from "../index";
const zApiKeySchema = z.object({
id: z.string(),
name: z.string(),
key: z.string(),
createdAt: z.date(),
});
export const apiKeysAppRouter = router({
create: authedProcedure
.input(
z.object({
name: z.string(),
}),
)
.output(zApiKeySchema)
.mutation(async ({ input, ctx }) => {
return await generateApiKey(input.name, ctx.user.id);
}),
revoke: authedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await ctx.db
.delete(apiKeys)
.where(and(eq(apiKeys.id, input.id), eq(apiKeys.userId, ctx.user.id)));
}),
list: authedProcedure
.output(
z.object({
keys: z.array(
z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
keyId: z.string(),
}),
),
}),
)
.query(async ({ ctx }) => {
const resp = await ctx.db.query.apiKeys.findMany({
where: eq(apiKeys.userId, ctx.user.id),
columns: {
id: true,
name: true,
createdAt: true,
keyId: true,
},
});
return { keys: resp };
}),
// Exchange the username and password with an API key.
// Homemade oAuth. This is used by the extension.
exchange: publicProcedure
.input(
z.object({
keyName: z.string(),
email: z.string(),
password: z.string(),
}),
)
.output(zApiKeySchema)
.mutation(async ({ input, ctx }) => {
let user;
// Special handling as otherwise the extension would show "username or password is wrong"
if (serverConfig.auth.disablePasswordAuth) {
throw new TRPCError({
message: "Password authentication is currently disabled",
code: "FORBIDDEN",
});
}
try {
user = await validatePassword(input.email, input.password);
} catch (e) {
const error = e as Error;
logAuthenticationError(input.email, error.message, ctx.req.ip);
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return await generateApiKey(input.keyName, user.id);
}),
validate: publicProcedure
.input(z.object({ apiKey: z.string() }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ input, ctx }) => {
try {
await authenticateApiKey(input.apiKey); // Throws if the key is invalid
return {
success: true,
};
} catch (e) {
const error = e as Error;
logAuthenticationError("<unknown>", error.message, ctx.req.ip);
throw e;
}
}),
});