main
Ethan Niser 2023-09-21 20:48:14 -05:00
parent 22bff4dc17
commit 21f2ff73b2
11 changed files with 252 additions and 80 deletions

Binary file not shown.

@ -40,6 +40,7 @@
"@iconify-json/lucide": "^1.1.128",
"@libsql/client": "0.3.5-pre.9",
"@lucia-auth/adapter-sqlite": "^2.0.0",
"@lucia-auth/oauth": "^3.2.0",
"@t3-oss/env-core": "^0.6.1",
"@tlscipher/holt": "1.1.0",
"beth-stack": "0.0.27",

@ -1,4 +1,5 @@
import { libsql } from "@lucia-auth/adapter-sqlite";
import { github } from "@lucia-auth/oauth/providers";
import { lucia, Middleware } from "lucia";
import { config } from "../config";
import { client } from "../db";
@ -54,3 +55,8 @@ export const auth = lucia({
});
export type Auth = typeof auth;
export const githubAuth = github(auth, {
clientId: config.env.GITHUB_CLIENT_ID,
clientSecret: config.env.GITHUB_CLIENT_SECRET,
});

@ -21,7 +21,10 @@ export const BaseHtml = ({ children }: PropsWithChildren) => (
<link rel="stylesheet" href="/public/dist/unocss.css" />
<script>{safeScript}</script>
</head>
<body hx-boost="true" hx-ext="response-targets">
<body hx-boost="true" class="h-screen">
<h1 class=" bg-blue-500 p-5 text-center text-3xl font-bold text-white shadow-md">
Create BETH App
</h1>
{children}
</body>
</html>

@ -5,22 +5,39 @@ export function TweetCard({
author: { handle },
createdAt,
content,
id,
}: {
createdAt: Date;
content: string;
author: {
handle: string;
};
id: number;
}) {
return (
<div class="rounded-lg border p-4 shadow-md">
<div
class="rounded-lg border p-4 shadow-md"
id={`tweet-${id}`}
hx-ext="response-targets"
>
<h2 class="text-xl font-bold" safe>
@{handle}
</h2>
<p class="text-gray-700" safe>
{content}
</p>
<span class="text-sm text-gray-500">{createdAt.toLocaleString()}</span>
<div class="flex flex-row justify-between">
<span class="text-sm text-gray-500">{createdAt.toLocaleString()}</span>
<button
class="i-lucide-x text-lg text-red-500"
hx-delete={`/api/tweets/${id}`}
hx-target={`#tweet-${id}`}
hx-swap="outerHTML"
hx-target-4xx="next #tweetDeleteError"
hx-confirm="Are you sure you want to delete this tweet?"
/>
</div>
<div id="tweetDeleteError" />
</div>
);
}

@ -1,18 +1,24 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
const env = createEnv({
server: {
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]),
DATABASE_CONNECTION_TYPE: z.enum(["local", "remote", "local-replica"]),
DATABASE_URL: z.string().min(1),
DATABASE_AUTH_TOKEN: z.string().optional().refine((s) => {
// not needed for local only
const type = process.env.DATABASE_CONNECTION_TYPE;
return type === "remote" || type === "local-replica" ? s && s.length > 0 : true;
}),
DATABASE_AUTH_TOKEN: z
.string()
.optional()
.refine((s) => {
// not needed for local only
const type = process.env.DATABASE_CONNECTION_TYPE;
return type === "remote" || type === "local-replica"
? s && s.length > 0
: true;
}),
NODE_ENV: z.enum(["development", "production"]),
GITHUB_CLIENT_ID: z.string().min(1),
GITHUB_CLIENT_SECRET: z.string().min(1),
},
runtimeEnv: process.env,
});

@ -1,3 +1,4 @@
import { eq } from "drizzle-orm";
import { Elysia, t } from "elysia";
import { authed } from "../auth/middleware";
import { AdditionalTweetList, TweetCard } from "../components/tweets";
@ -55,6 +56,7 @@ export const tweetsController = new Elysia({
content={tweet.content}
createdAt={tweet.createdAt}
author={{ handle: session.user.handle }}
id={tweet.id}
/>
);
},
@ -66,4 +68,49 @@ export const tweetsController = new Elysia({
}),
}),
},
)
.delete(
"/:tweetId",
async ({ session, db, params: { tweetId }, set, log }) => {
if (!session) {
set.status = "Unauthorized";
return (
<div id="tweetDeleteError" class="text-center text-red-500">
Unauthorized
</div>
);
}
const [tweet] = await db
.select()
.from(tweets)
.where(eq(tweets.id, tweetId));
log.debug(tweet);
if (!tweet) {
set.status = "Not Found";
return (
<div id="tweetDeleteError" class="text-center text-red-500">
Tweet not found
</div>
);
}
if (tweet.authorId !== session.user.userId) {
set.status = "Unauthorized";
return (
<div id="tweetDeleteError" class="text-center text-red-500">
Unauthorized
</div>
);
}
await db.delete(tweets).where(eq(tweets.id, tweetId));
},
{
params: t.Object({
tweetId: t.Numeric(),
}),
},
);

@ -15,6 +15,7 @@ export const tweets = sqliteTable(
(table) => {
return {
authorIdx: index("author_idx").on(table.authorId),
createdAtIdx: index("created_at_idx").on(table.createdAt),
};
},
);

@ -1,6 +1,4 @@
import Elysia from "elysia";
// import { signup } from "./signup";
import { login } from "./signin";
import { signin } from "./signin";
export const authGroup = new Elysia().use(signin);
export const authGroup = new Elysia().use(login);

@ -1,74 +1,167 @@
import { OAuthRequestError } from "@lucia-auth/oauth";
import Elysia from "elysia";
import { parseCookie, serializeCookie } from "lucia/utils";
import { githubAuth } from "../../auth";
import { BaseHtml } from "../../components/base";
import { config } from "../../config";
import { ctx } from "../../context";
export const signin = new Elysia().use(ctx).get("/signin", ({ html }) =>
html(() => (
<BaseHtml>
<div class="flex h-screen w-full flex-col items-center justify-center bg-gray-200">
<div class="p-4">
<a
href="/"
class="text-indigo-600 hover:text-indigo-800 hover:underline"
>
Go Home
</a>
</div>
<form
hx-post="/api/auth/signInOrUp"
hx-swap="innerHTML"
hx-target-4xx="#errorMessage"
class="w-96 rounded-lg bg-white p-8 shadow-md"
export const login = new Elysia()
.use(ctx)
.get("/login", async (ctx) => {
const authRequest = ctx.auth.handleRequest(ctx);
const session = await authRequest.validate();
if (session) {
ctx.set.redirect = "/";
return;
}
return ctx.html(() => (
<BaseHtml>
<div
class="flex h-screen w-full flex-col items-center justify-center bg-gray-200"
hx-ext="response-targets"
>
<div class="mb-4">
<label
for="handle"
class="mb-2 block text-sm font-medium text-gray-600"
<div class="p-4">
<a
href="/"
class="text-indigo-600 hover:text-indigo-800 hover:underline"
>
Handle
</label>
<input
type="text"
name="handle"
id="handle"
placeholder="Enter your handle"
class="w-full rounded-md border p-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
Go Home
</a>
</div>
<div class="mb-4">
<label
for="password"
class="mb-2 block text-sm font-medium text-gray-600"
>
Password
</label>
<input
type="password"
name="password"
id="password"
placeholder="Enter your password"
class="w-full rounded-md border p-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
</div>
<button
type="submit"
name="action"
value="signin"
class="mb-2 w-full rounded-md bg-indigo-600 p-2 text-white hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-opacity-50"
>
Sign In
</button>
<button
type="submit"
name="action"
value="signup"
class="w-full rounded-md bg-green-600 p-2 text-white hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-50"
<form
hx-post="/api/auth/signInOrUp"
hx-swap="innerHTML"
hx-target-4xx="#errorMessage"
class="w-96 rounded-lg bg-white p-8 shadow-md"
>
Sign Up
</button>
<div id="errorMessage" class="mt-4 text-red-500"></div>
</form>
</div>
</BaseHtml>
)),
);
<div class="mb-4">
<label
for="handle"
class="mb-2 block text-sm font-medium text-gray-600"
>
Handle
</label>
<input
type="text"
name="handle"
id="handle"
placeholder="Enter your handle"
class="w-full rounded-md border p-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
</div>
<div class="mb-4">
<label
for="password"
class="mb-2 block text-sm font-medium text-gray-600"
>
Password
</label>
<input
type="password"
name="password"
id="password"
placeholder="Enter your password"
class="w-full rounded-md border p-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
</div>
<div class="flex flex-col gap-2">
<button
type="submit"
name="action"
value="signin"
class="w-full rounded-md bg-indigo-600 p-2 text-white hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-opacity-50"
>
Sign In
</button>
<button
type="submit"
name="action"
value="signup"
class="w-full rounded-md bg-green-600 p-2 text-white hover:bg-green-500 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-50"
>
Sign Up
</button>
<a
hx-boost="false"
href="/login/github"
class="display-block rounded-lg bg-gray-800 p-2 text-center text-white transition duration-200 hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-opacity-50"
>
Sign In with Github
<div class="i-logos-github-icon inline-block text-2xl" />
</a>
</div>
<div id="errorMessage" class="pt-4 text-red-500"></div>
</form>
</div>
</BaseHtml>
));
})
.get("/login/github", async ({ set }) => {
const [url, state] = await githubAuth.getAuthorizationUrl();
const stateCookie = serializeCookie("github_oauth_state", state, {
maxAge: 60 * 60,
secure: config.env.NODE_ENV === "production",
httpOnly: true,
path: "/",
});
set.headers["Set-Cookie"] = stateCookie;
set.redirect = url.toString();
})
.get(
"/login/github/callback",
async ({ request, log, path, query, set, auth }) => {
const { code, state } = query;
const cookies = parseCookie(request.headers.get("Cookie") ?? "");
const storedState = cookies.github_oauth_state;
if (!storedState || !state || storedState !== state || !code) {
set.status = 400;
return "Invalid state";
}
try {
const { getExistingUser, githubUser, createUser } =
await githubAuth.validateCallback(code);
const getUser = async () => {
const existingUser = await getExistingUser();
if (existingUser) return existingUser;
const user = await createUser({
attributes: {
handle: githubUser.login,
},
});
return user;
};
const user = await getUser();
const session = await auth.createSession({
userId: user.userId,
attributes: {},
});
const sessionCookie = auth.createSessionCookie(session);
// redirect to profile page
return new Response(null, {
headers: {
Location: "/",
"Set-Cookie": sessionCookie.serialize(), // store session cookie
},
status: 302,
});
} catch (e) {
if (e instanceof OAuthRequestError) {
// invalid code
set.status = 400;
return e.message;
}
set.status = 500;
log.error(e);
return "Internal server error";
}
},
);

@ -32,7 +32,7 @@ export const index = new Elysia()
</>
) : (
<a
href="/signin"
href="/login"
class="mt-4 rounded-lg bg-blue-500 px-4 py-2 text-white transition duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-50"
>
Sign In