+3
deno.lock
+3
deno.lock
···
160
160
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
161
161
}
162
162
},
163
+
"remote": {
164
+
"https://esm.smallweb.run/slice@e3c3789/src/generated_client.ts": "d2b713a3ae4f4d9b130fe308f9228063fb950ce0f8921facd7fcbf3a6f6732b8"
165
+
},
163
166
"workspace": {
164
167
"dependencies": [
165
168
"jsr:@slices/client@~0.1.0-alpha.4",
+3
main.ts
+3
main.ts
+1
-1
slices.json
+1
-1
slices.json
+1
-1
smallweb.json
+1
-1
smallweb.json
-75
src/config.ts
-75
src/config.ts
···
1
-
import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth";
2
-
import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session";
3
-
import { AtProtoClient } from "./generated_client.ts";
4
-
5
-
const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID");
6
-
const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET");
7
-
const OAUTH_REDIRECT_URI = Deno.env.get("OAUTH_REDIRECT_URI");
8
-
const OAUTH_AIP_BASE_URL = Deno.env.get("OAUTH_AIP_BASE_URL");
9
-
const API_URL = Deno.env.get("API_URL");
10
-
export const SLICE_URI = Deno.env.get("SLICE_URI");
11
-
12
-
if (
13
-
!OAUTH_CLIENT_ID ||
14
-
!OAUTH_CLIENT_SECRET ||
15
-
!OAUTH_REDIRECT_URI ||
16
-
!OAUTH_AIP_BASE_URL ||
17
-
!API_URL ||
18
-
!SLICE_URI
19
-
) {
20
-
throw new Error(
21
-
"Missing OAuth configuration. Please ensure .env file contains:\n" +
22
-
"OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_AIP_BASE_URL, API_URL, SLICE_URI"
23
-
);
24
-
}
25
-
26
-
const DATABASE_URL = Deno.env.get("DATABASE_URL") || "slices.db";
27
-
28
-
// OAuth setup
29
-
const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL);
30
-
const oauthConfig = {
31
-
clientId: OAUTH_CLIENT_ID,
32
-
clientSecret: OAUTH_CLIENT_SECRET,
33
-
authBaseUrl: OAUTH_AIP_BASE_URL,
34
-
redirectUri: OAUTH_REDIRECT_URI,
35
-
scopes: ["atproto", "openid", "profile"],
36
-
};
37
-
38
-
// Export config and storage for creating user-scoped clients
39
-
export { oauthConfig, oauthStorage };
40
-
41
-
// Session setup (shared database)
42
-
export const sessionStore = new SessionStore({
43
-
adapter: new SQLiteAdapter(DATABASE_URL),
44
-
cookieName: "slice-session",
45
-
cookieOptions: {
46
-
httpOnly: true,
47
-
secure: Deno.env.get("DENO_ENV") === "production",
48
-
sameSite: "lax",
49
-
path: "/",
50
-
},
51
-
});
52
-
53
-
// OAuth + Session integration
54
-
export const oauthSessions = withOAuthSession(
55
-
sessionStore,
56
-
oauthConfig,
57
-
oauthStorage,
58
-
{
59
-
autoRefresh: true,
60
-
}
61
-
);
62
-
63
-
// Helper function to create user-scoped OAuth client
64
-
export function createOAuthClient(userId: string): OAuthClient {
65
-
return new OAuthClient(oauthConfig, oauthStorage, userId);
66
-
}
67
-
68
-
// Helper function to create authenticated AtProto client for a user
69
-
export function createSessionClient(userId: string): AtProtoClient {
70
-
const userOAuthClient = createOAuthClient(userId);
71
-
return new AtProtoClient(API_URL!, SLICE_URI!, userOAuthClient);
72
-
}
73
-
74
-
// Public client for unauthenticated requests
75
-
export const publicClient = new AtProtoClient(API_URL, SLICE_URI);
-169
src/features/auth/handlers.tsx
-169
src/features/auth/handlers.tsx
···
1
-
import type { Route } from "@std/http/unstable-route";
2
-
import { withAuth } from "../../routes/middleware.ts";
3
-
import { OAuthClient } from "@slices/oauth";
4
-
import {
5
-
createOAuthClient,
6
-
createSessionClient,
7
-
oauthConfig,
8
-
oauthStorage,
9
-
oauthSessions,
10
-
sessionStore,
11
-
} from "../../config.ts";
12
-
import { renderHTML } from "../../utils/render.tsx";
13
-
import { LoginPage } from "./templates/LoginPage.tsx";
14
-
15
-
async function handleLoginPage(req: Request): Promise<Response> {
16
-
const context = await withAuth(req);
17
-
const url = new URL(req.url);
18
-
19
-
// Redirect if already logged in
20
-
if (context.currentUser) {
21
-
return Response.redirect(new URL("/dashboard", req.url), 302);
22
-
}
23
-
24
-
const error = url.searchParams.get("error");
25
-
return renderHTML(<LoginPage error={error || undefined} />);
26
-
}
27
-
28
-
async function handleOAuthAuthorize(req: Request): Promise<Response> {
29
-
try {
30
-
const formData = await req.formData();
31
-
const loginHint = formData.get("loginHint") as string;
32
-
33
-
if (!loginHint) {
34
-
return new Response("Missing login hint", { status: 400 });
35
-
}
36
-
37
-
const tempOAuthClient = new OAuthClient(
38
-
oauthConfig,
39
-
oauthStorage,
40
-
loginHint
41
-
);
42
-
const authResult = await tempOAuthClient.authorize({
43
-
loginHint,
44
-
});
45
-
46
-
return Response.redirect(authResult.authorizationUrl, 302);
47
-
} catch (error) {
48
-
console.error("OAuth authorize error:", error);
49
-
50
-
return Response.redirect(
51
-
new URL(
52
-
"/login?error=" +
53
-
encodeURIComponent("Please check your handle and try again."),
54
-
req.url
55
-
),
56
-
302
57
-
);
58
-
}
59
-
}
60
-
61
-
async function handleOAuthCallback(req: Request): Promise<Response> {
62
-
try {
63
-
const url = new URL(req.url);
64
-
const code = url.searchParams.get("code");
65
-
const state = url.searchParams.get("state");
66
-
67
-
if (!code || !state) {
68
-
return Response.redirect(
69
-
new URL(
70
-
"/login?error=" + encodeURIComponent("Invalid OAuth callback"),
71
-
req.url
72
-
),
73
-
302
74
-
);
75
-
}
76
-
77
-
const tempOAuthClient = new OAuthClient(oauthConfig, oauthStorage, "temp");
78
-
const tokens = await tempOAuthClient.handleCallback({ code, state });
79
-
const sessionId = await oauthSessions.createOAuthSession(tokens);
80
-
81
-
if (!sessionId) {
82
-
return Response.redirect(
83
-
new URL(
84
-
"/login?error=" + encodeURIComponent("Failed to create session"),
85
-
req.url
86
-
),
87
-
302
88
-
);
89
-
}
90
-
91
-
const sessionCookie = sessionStore.createSessionCookie(sessionId);
92
-
93
-
let userInfo;
94
-
try {
95
-
const sessionOAuthClient = createOAuthClient(sessionId);
96
-
userInfo = await sessionOAuthClient.getUserInfo();
97
-
} catch (error) {
98
-
console.error("Failed to get user info:", error);
99
-
}
100
-
101
-
if (userInfo?.sub) {
102
-
try {
103
-
const userClient = createSessionClient(sessionId);
104
-
await userClient.syncUserCollections();
105
-
console.log("Synced Bluesky profile for", userInfo.sub);
106
-
} catch (error) {
107
-
console.error("Error syncing Bluesky profile:", error);
108
-
}
109
-
}
110
-
111
-
return new Response(null, {
112
-
status: 302,
113
-
headers: {
114
-
Location: new URL("/dashboard", req.url).toString(),
115
-
"Set-Cookie": sessionCookie,
116
-
},
117
-
});
118
-
} catch (error) {
119
-
console.error("OAuth callback error:", error);
120
-
return Response.redirect(
121
-
new URL(
122
-
"/login?error=" + encodeURIComponent("Authentication failed"),
123
-
req.url
124
-
),
125
-
302
126
-
);
127
-
}
128
-
}
129
-
130
-
async function handleLogout(req: Request): Promise<Response> {
131
-
const session = await sessionStore.getSessionFromRequest(req);
132
-
133
-
if (session) {
134
-
await oauthSessions.logout(session.sessionId);
135
-
}
136
-
137
-
const clearCookie = sessionStore.createLogoutCookie();
138
-
139
-
return new Response(null, {
140
-
status: 302,
141
-
headers: {
142
-
Location: new URL("/login", req.url).toString(),
143
-
"Set-Cookie": clearCookie,
144
-
},
145
-
});
146
-
}
147
-
148
-
export const authRoutes: Route[] = [
149
-
{
150
-
method: "GET",
151
-
pattern: new URLPattern({ pathname: "/login" }),
152
-
handler: handleLoginPage,
153
-
},
154
-
{
155
-
method: "POST",
156
-
pattern: new URLPattern({ pathname: "/oauth/authorize" }),
157
-
handler: handleOAuthAuthorize,
158
-
},
159
-
{
160
-
method: "GET",
161
-
pattern: new URLPattern({ pathname: "/oauth/callback" }),
162
-
handler: handleOAuthCallback,
163
-
},
164
-
{
165
-
method: "POST",
166
-
pattern: new URLPattern({ pathname: "/logout" }),
167
-
handler: handleLogout,
168
-
},
169
-
];
-56
src/features/auth/templates/LoginPage.tsx
-56
src/features/auth/templates/LoginPage.tsx
···
1
-
import { Layout } from "../../../shared/fragments/Layout.tsx";
2
-
import { Button } from "../../../shared/fragments/Button.tsx";
3
-
import { Input } from "../../../shared/fragments/Input.tsx";
4
-
5
-
interface LoginPageProps {
6
-
error?: string;
7
-
}
8
-
9
-
export function LoginPage({ error }: LoginPageProps) {
10
-
return (
11
-
<Layout title="Login">
12
-
<div className="min-h-screen flex items-center justify-center bg-gray-50">
13
-
<div className="max-w-md w-full space-y-8">
14
-
<div>
15
-
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
16
-
Sign in to your account
17
-
</h2>
18
-
<p className="mt-2 text-center text-sm text-gray-600">
19
-
Use your AT Protocol handle or DID
20
-
</p>
21
-
</div>
22
-
23
-
{error && (
24
-
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
25
-
{error === "OAuth initialization failed" && "Failed to start authentication"}
26
-
{error === "Invalid OAuth callback" && "Authentication callback failed"}
27
-
{error === "Authentication failed" && "Authentication failed"}
28
-
{error === "Failed to create session" && "Failed to create session"}
29
-
{!["OAuth initialization failed", "Invalid OAuth callback", "Authentication failed", "Failed to create session"].includes(error) && error}
30
-
</div>
31
-
)}
32
-
33
-
<form className="mt-8 space-y-6" action="/oauth/authorize" method="post">
34
-
<div>
35
-
<label htmlFor="loginHint" className="block text-sm font-medium text-gray-700">
36
-
Handle or DID
37
-
</label>
38
-
<Input
39
-
id="loginHint"
40
-
name="loginHint"
41
-
type="text"
42
-
required
43
-
placeholder="alice.bsky.social or did:plc:..."
44
-
className="mt-1"
45
-
/>
46
-
</div>
47
-
48
-
<Button type="submit" className="w-full">
49
-
Sign in
50
-
</Button>
51
-
</form>
52
-
</div>
53
-
</div>
54
-
</Layout>
55
-
);
56
-
}
-49
src/features/dashboard/handlers.tsx
-49
src/features/dashboard/handlers.tsx
···
1
-
import type { Route } from "@std/http/unstable-route";
2
-
import { withAuth } from "../../routes/middleware.ts";
3
-
import { renderHTML } from "../../utils/render.tsx";
4
-
import { DashboardPage } from "./templates/DashboardPage.tsx";
5
-
import { publicClient } from "../../config.ts";
6
-
import { recordBlobToCdnUrl } from "@slices/client";
7
-
import { AppBskyActorProfile } from "../../generated_client.ts";
8
-
9
-
async function handleDashboard(req: Request): Promise<Response> {
10
-
const context = await withAuth(req);
11
-
12
-
if (!context.currentUser) {
13
-
return Response.redirect(new URL("/login", req.url), 302);
14
-
}
15
-
16
-
let profile: AppBskyActorProfile | undefined;
17
-
let avatarUrl: string | undefined;
18
-
try {
19
-
const profileResult = await publicClient.app.bsky.actor.profile.getRecord({
20
-
uri: `at://${context.currentUser.sub}/app.bsky.actor.profile/self`,
21
-
});
22
-
23
-
if (profileResult) {
24
-
profile = profileResult.value;
25
-
26
-
if (profile.avatar) {
27
-
avatarUrl = recordBlobToCdnUrl(profileResult, profile.avatar, "avatar");
28
-
}
29
-
}
30
-
} catch (error) {
31
-
console.error("Error fetching profile:", error);
32
-
}
33
-
34
-
return renderHTML(
35
-
<DashboardPage
36
-
currentUser={context.currentUser}
37
-
profile={profile}
38
-
avatarUrl={avatarUrl}
39
-
/>
40
-
);
41
-
}
42
-
43
-
export const dashboardRoutes: Route[] = [
44
-
{
45
-
method: "GET",
46
-
pattern: new URLPattern({ pathname: "/dashboard" }),
47
-
handler: handleDashboard,
48
-
},
49
-
];
-56
src/features/dashboard/templates/DashboardPage.tsx
-56
src/features/dashboard/templates/DashboardPage.tsx
···
1
-
import { Layout } from "../../../shared/fragments/Layout.tsx";
2
-
import { Button } from "../../../shared/fragments/Button.tsx";
3
-
import type { AppBskyActorProfile } from "../../../generated_client.ts";
4
-
5
-
interface DashboardPageProps {
6
-
currentUser: {
7
-
name?: string;
8
-
sub: string;
9
-
};
10
-
profile?: AppBskyActorProfile;
11
-
avatarUrl?: string;
12
-
}
13
-
14
-
export function DashboardPage({
15
-
currentUser,
16
-
profile,
17
-
avatarUrl,
18
-
}: DashboardPageProps) {
19
-
return (
20
-
<Layout title="Dashboard">
21
-
<div className="min-h-screen bg-gray-50 p-8">
22
-
<div className="max-w-2xl mx-auto">
23
-
<div className="bg-white rounded-lg shadow p-6">
24
-
<div className="flex justify-between items-center mb-6">
25
-
<h1 className="text-2xl font-bold">Dashboard</h1>
26
-
<form method="post" action="/logout">
27
-
<Button type="submit" variant="secondary">
28
-
Logout
29
-
</Button>
30
-
</form>
31
-
</div>
32
-
33
-
<div className="mb-6">
34
-
{avatarUrl && (
35
-
<img
36
-
src={avatarUrl}
37
-
alt="Profile"
38
-
className="w-20 h-20 rounded-full mb-4"
39
-
/>
40
-
)}
41
-
<h2 className="text-xl font-semibold mb-2">
42
-
{profile?.displayName || currentUser.name || currentUser.sub}
43
-
</h2>
44
-
{currentUser.name && (
45
-
<p className="text-gray-600 mb-2">@{currentUser.name}</p>
46
-
)}
47
-
{profile?.description && (
48
-
<p className="text-gray-700 mt-2">{profile.description}</p>
49
-
)}
50
-
</div>
51
-
</div>
52
-
</div>
53
-
</div>
54
-
</Layout>
55
-
);
56
-
}
+1
-1
src/generated_client.ts
client.ts
+1
-1
src/generated_client.ts
client.ts
-14
src/main.ts
-14
src/main.ts
···
1
-
import { route } from "@std/http/unstable-route";
2
-
import { allRoutes } from "./routes/mod.ts";
3
-
import { createLoggingHandler } from "./utils/logging.ts";
4
-
5
-
function defaultHandler(req: Request): Promise<Response> {
6
-
return Promise.resolve(Response.redirect(new URL("/", req.url), 302));
7
-
}
8
-
9
-
const handler = createLoggingHandler(route(allRoutes, defaultHandler));
10
-
11
-
export default {
12
-
fetch: handler,
13
-
}
14
-
-43
src/routes/middleware.ts
-43
src/routes/middleware.ts
···
1
-
import { sessionStore, createOAuthClient } from "../config.ts";
2
-
3
-
export interface AuthContext {
4
-
currentUser: {
5
-
sub: string;
6
-
name?: string;
7
-
email?: string;
8
-
} | null;
9
-
sessionId: string | null;
10
-
}
11
-
12
-
export async function withAuth(req: Request): Promise<AuthContext> {
13
-
const session = await sessionStore.getSessionFromRequest(req);
14
-
15
-
if (!session) {
16
-
return { currentUser: null, sessionId: null };
17
-
}
18
-
19
-
try {
20
-
const sessionOAuthClient = createOAuthClient(session.sessionId);
21
-
const userInfo = await sessionOAuthClient.getUserInfo();
22
-
return {
23
-
currentUser: userInfo || null,
24
-
sessionId: session.sessionId,
25
-
};
26
-
} catch {
27
-
return { currentUser: null, sessionId: session.sessionId };
28
-
}
29
-
}
30
-
31
-
export function requireAuth(
32
-
handler: (req: Request, context: AuthContext) => Promise<Response>
33
-
) {
34
-
return async (req: Request): Promise<Response> => {
35
-
const context = await withAuth(req);
36
-
37
-
if (!context.currentUser) {
38
-
return Response.redirect(new URL("/login", req.url), 302);
39
-
}
40
-
41
-
return handler(req, context);
42
-
};
43
-
}
-24
src/routes/mod.ts
-24
src/routes/mod.ts
···
1
-
import type { Route } from "@std/http/unstable-route";
2
-
import { authRoutes } from "../features/auth/handlers.tsx";
3
-
import { dashboardRoutes } from "../features/dashboard/handlers.tsx";
4
-
5
-
export const allRoutes: Route[] = [
6
-
// Root redirect to login for now
7
-
{
8
-
method: "GET",
9
-
pattern: new URLPattern({ pathname: "/" }),
10
-
handler: (req) => Response.redirect(new URL("/login", req.url), 302),
11
-
},
12
-
13
-
{
14
-
method: "GET",
15
-
pattern: new URLPattern({ pathname: "/admin" }),
16
-
handler: () => Response.redirect("https://slices.network/profile/pomdtr.me/slice/3m25liqjjnh2o", 302),
17
-
},
18
-
19
-
// Auth routes
20
-
...authRoutes,
21
-
22
-
// Dashboard routes
23
-
...dashboardRoutes,
24
-
];
-6
src/utils/cn.ts
-6
src/utils/cn.ts
-43
src/utils/logging.ts
-43
src/utils/logging.ts
···
1
-
import { cyan, green, red, yellow, bold, dim } from "@std/fmt/colors";
2
-
3
-
export function createLoggingHandler(
4
-
handler: (req: Request) => Response | Promise<Response>
5
-
) {
6
-
return async (req: Request): Promise<Response> => {
7
-
const start = Date.now();
8
-
const method = req.method;
9
-
const url = new URL(req.url);
10
-
11
-
try {
12
-
const response = await Promise.resolve(handler(req));
13
-
const duration = Date.now() - start;
14
-
15
-
const methodColor = cyan(bold(method));
16
-
const statusColor =
17
-
response.status >= 200 && response.status < 300
18
-
? green(String(response.status))
19
-
: response.status >= 300 && response.status < 400
20
-
? yellow(String(response.status))
21
-
: response.status >= 400
22
-
? red(String(response.status))
23
-
: String(response.status);
24
-
const durationText = dim(`(${duration}ms)`);
25
-
26
-
console.log(
27
-
`${methodColor} ${url.pathname} - ${statusColor} ${durationText}`
28
-
);
29
-
return response;
30
-
} catch (error) {
31
-
const duration = Date.now() - start;
32
-
const methodColor = cyan(bold(method));
33
-
const errorText = red(bold("ERROR"));
34
-
const durationText = dim(`(${duration}ms)`);
35
-
36
-
console.error(
37
-
`${methodColor} ${url.pathname} - ${errorText} ${durationText}:`,
38
-
error
39
-
);
40
-
throw error;
41
-
}
42
-
};
43
-
}
-12
src/utils/render.tsx
-12
src/utils/render.tsx
···
1
-
import { renderToString } from "preact-render-to-string";
2
-
import { VNode } from "preact";
3
-
4
-
export function renderHTML(element: VNode): Response {
5
-
const html = renderToString(element);
6
-
7
-
return new Response(html, {
8
-
headers: {
9
-
"Content-Type": "text/html; charset=utf-8",
10
-
},
11
-
});
12
-
}