CLI hello-world works and talks to TRPC! And starting to clean things up
This commit is contained in:
parent
0fd8ce7189
commit
ea2a9da1e0
@ -15,8 +15,9 @@ helloWorldCmd
|
||||
.description("does something specific I guess")
|
||||
.action(async () => {
|
||||
const api = getAPIClient();
|
||||
const whoami = await api.users.whoami.query();
|
||||
try {
|
||||
console.dir(api);
|
||||
console.log("Hello " + whoami.name);
|
||||
} catch (error) {
|
||||
printErrorMessageWithReason(
|
||||
"Something went horribly wrong",
|
||||
|
||||
@ -5,9 +5,11 @@ import type { AppRouter } from "@lifetracker/trpc/routers/_app";
|
||||
|
||||
export function getAPIClient() {
|
||||
const globals = getGlobalOptions();
|
||||
|
||||
return createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
transformer: superjson,
|
||||
url: `${globals.serverAddr}/api/trpc`,
|
||||
maxURLLength: 14000,
|
||||
headers() {
|
||||
|
||||
@ -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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -16,7 +16,7 @@ import { api } from "@/lib/trpc";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { zChangePasswordSchema } from "@hoarder/shared/types/users";
|
||||
import { zChangePasswordSchema } from "@lifetracker/shared/types/users";
|
||||
|
||||
export function ChangePassword() {
|
||||
const form = useForm<z.infer<typeof zChangePasswordSchema>>({
|
||||
|
||||
@ -48,223 +48,8 @@ export function ExportButton() {
|
||||
export function ImportExportRow() {
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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>
|
||||
<div>TBD!</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import SidebarItem from "@/components/shared/sidebar/SidebarItem";
|
||||
import { getServerAuthSession } from "@/server/auth";
|
||||
|
||||
import serverConfig from "@hoarder/shared/config";
|
||||
import serverConfig from "@lifetracker/shared/config";
|
||||
|
||||
import { settingsSidebarItems } from "./items";
|
||||
|
||||
|
||||
@ -3,8 +3,6 @@ import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
KeyRound,
|
||||
Rss,
|
||||
Sparkles,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
@ -16,23 +14,13 @@ export const settingsSidebarItems: {
|
||||
{
|
||||
name: "Back To App",
|
||||
icon: <ArrowLeft size={18} />,
|
||||
path: "/dashboard/bookmarks",
|
||||
path: "/dashboard/today",
|
||||
},
|
||||
{
|
||||
name: "User Info",
|
||||
icon: <User size={18} />,
|
||||
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",
|
||||
icon: <Download size={18} />,
|
||||
|
||||
@ -17,6 +17,11 @@ export async function createContextFromRequest(req: Request) {
|
||||
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
|
||||
const token = authorizationHeader.split(" ")[1];
|
||||
try {
|
||||
|
||||
console.log("\n\nEeeeeeentering hell\n\n");
|
||||
console.log(await authenticateApiKey(token));
|
||||
console.log("\n\n\n\n");
|
||||
|
||||
const user = await authenticateApiKey(token);
|
||||
return {
|
||||
user,
|
||||
|
||||
@ -8,9 +8,7 @@ import path from "path";
|
||||
import dbConfig from "./drizzle.config";
|
||||
|
||||
const sqlite = new Database(dbConfig.dbCredentials.url);
|
||||
|
||||
console.log(dbConfig.dbCredentials.url);
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export const db = drizzle(sqlite, { schema, logger: true });
|
||||
|
||||
export function getInMemoryDB(runMigrations: boolean) {
|
||||
const mem = new Database(":memory:");
|
||||
|
||||
4
packages/db/migrations/0004_sad_sally_floyd.sql
Normal file
4
packages/db/migrations/0004_sad_sally_floyd.sql
Normal file
@ -0,0 +1,4 @@
|
||||
CREATE TABLE `config` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
423
packages/db/migrations/meta/0004_snapshot.json
Normal file
423
packages/db/migrations/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,13 @@
|
||||
"when": 1731542202120,
|
||||
"tag": "0003_complex_ares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1731637559763,
|
||||
"tag": "0004_sad_sally_floyd",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -104,3 +104,17 @@ export const days = sqliteTable("day", {
|
||||
date: text("date").notNull().unique(),
|
||||
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],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -54,7 +54,21 @@ function parseApiKey(plain: string) {
|
||||
}
|
||||
|
||||
export async function authenticateApiKey(key: string) {
|
||||
|
||||
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({
|
||||
where: (k, { eq }) => eq(k.keyId, keyId),
|
||||
with: {
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { apiKeys } from "@lifetracker/db/schema";
|
||||
import { router } from "../index";
|
||||
import { usersAppRouter } from "./users";
|
||||
import { apiKeysAppRouter } from "./apiKeys";
|
||||
|
||||
export const appRouter = router({
|
||||
users: usersAppRouter
|
||||
users: usersAppRouter,
|
||||
apiKeys: apiKeysAppRouter,
|
||||
});
|
||||
// export type definition of API
|
||||
export type AppRouter = typeof appRouter;
|
||||
114
packages/trpc/routers/apiKeys.ts
Normal file
114
packages/trpc/routers/apiKeys.ts
Normal 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;
|
||||
}
|
||||
}),
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user