tangled
alpha
login
or
join now
dbushell.com
/
attic.social
11
fork
atom
Attic is a cozy space with lofty ambitions.
attic.social
11
fork
atom
overview
issues
pulls
pipelines
display name record
dbushell.com
1 week ago
2cbb4fa4
ac1b222b
verified
This commit was signed with the committer's
known signature
.
dbushell.com
SSH Key Fingerprint:
SHA256:Sj5AfJ6VbC0PEnnQD2kGGEiGFwHdFBS/ypN5oifzzFI=
+196
-81
12 changed files
expand all
collapse all
unified
split
package.json
src
css
main.css
hooks.server.ts
lexicons
index.ts
types
social
attic
actor
profile.ts
lib
server
oauth.ts
session.ts
valibot.ts
routes
+page.server.ts
+page.svelte
oauth
callback
+server.ts
tsconfig.json
+2
-2
package.json
···
4
4
"version": "0.0.1",
5
5
"type": "module",
6
6
"scripts": {
7
7
-
"lex": "lex-cli generate",
8
7
"dev": "vite dev",
9
8
"build": "vite build",
10
9
"preview": "vite preview",
11
10
"prepare": "svelte-kit sync || echo ''",
12
11
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
13
13
-
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
12
12
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
13
13
+
"lex:generate": "lex-cli generate"
14
14
},
15
15
"dependencies": {
16
16
"@atcute/atproto": "^3.1.10",
+11
-1
src/css/main.css
···
16
16
.avatar {
17
17
align-items: center;
18
18
display: grid;
19
19
+
column-gap: 10px;
19
20
grid-template-columns: 50px auto;
20
20
-
gap: 10px;
21
21
22
22
& img {
23
23
border-radius: calc(1px * infinity);
24
24
block-size: 50px;
25
25
inline-size: 50px;
26
26
+
grid-column: 1;
27
27
+
grid-row: 1 / 5;
26
28
}
27
29
28
30
& p {
29
31
font-weight: 700;
32
32
+
grid-column: 2;
33
33
+
grid-row: 2;
34
34
+
line-height: 1.25;
35
35
+
36
36
+
& + & {
37
37
+
font-weight: 400;
38
38
+
grid-row: 3;
39
39
+
}
30
40
}
31
41
}
+9
-1
src/hooks.server.ts
···
1
1
import { dev } from "$app/environment";
2
2
-
import { restoreSession } from "$lib/server/session.ts";
3
2
import type { Handle } from "@sveltejs/kit";
4
3
import { sequence } from "@sveltejs/kit/hooks";
4
4
+
import { restoreSession } from "./lib/server/session.ts";
5
5
6
6
/**
7
7
* {@link https://svelte.dev/docs/cli/devtools-json}
···
15
15
};
16
16
17
17
export const defaultHandle: Handle = async ({ event, resolve }) => {
18
18
+
// [TODO] necessary?
19
19
+
// if (
20
20
+
// event.url.searchParams.has("session") ||
21
21
+
// event.request.headers.has("x-session") ||
22
22
+
// event.request.headers.get("sec-fetch-mode") === "navigate" ||
23
23
+
// // event.request.headers.get("sec-fetch-dest") === "empty"
24
24
+
// ) {
18
25
await restoreSession(event);
26
26
+
// }
19
27
return resolve(event);
20
28
};
21
29
-1
src/lexicons/index.ts
···
1
1
-
export * as SocialAtticActorProfile from "./types/social/attic/actor/profile.ts";
-32
src/lexicons/types/social/attic/actor/profile.ts
···
1
1
-
import type {} from "@atcute/lexicons";
2
2
-
import * as v from "@atcute/lexicons/validations";
3
3
-
import type {} from "@atcute/lexicons/ambient";
4
4
-
5
5
-
const _mainSchema = /*#__PURE__*/ v.record(
6
6
-
/*#__PURE__*/ v.literal("self"),
7
7
-
/*#__PURE__*/ v.object({
8
8
-
$type: /*#__PURE__*/ v.literal("social.attic.actor.profile"),
9
9
-
/**
10
10
-
* @maxLength 640
11
11
-
* @maxGraphemes 64
12
12
-
*/
13
13
-
displayName: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [
14
14
-
/*#__PURE__*/ v.stringLength(0, 640),
15
15
-
/*#__PURE__*/ v.stringGraphemes(0, 64),
16
16
-
]),
17
17
-
}),
18
18
-
);
19
19
-
20
20
-
type main$schematype = typeof _mainSchema;
21
21
-
22
22
-
export interface mainSchema extends main$schematype {}
23
23
-
24
24
-
export const mainSchema = _mainSchema as mainSchema;
25
25
-
26
26
-
export interface Main extends v.InferInput<typeof mainSchema> {}
27
27
-
28
28
-
declare module "@atcute/lexicons/ambient" {
29
29
-
interface Records {
30
30
-
"social.attic.actor.profile": mainSchema;
31
31
-
}
32
32
-
}
+10
-2
src/lib/server/oauth.ts
···
1
1
import { dev } from "$app/environment";
2
2
+
import type {} from "$lexicons/index.ts";
2
3
import {
3
4
OAUTH_COOKIE_PREFIX,
4
5
OAUTH_MAX_AGE,
···
115
116
});
116
117
117
118
const redirect = new URL("/oauth/callback", event.platform.env.ORIGIN);
118
118
-
const scopes = [scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" })];
119
119
-
let metadata: OAuthClientOptions["metadata"];
119
119
+
120
120
+
const scopes = [
121
121
+
scope.rpc({ lxm: ["app.bsky.actor.getProfile"], aud: "*" }),
122
122
+
scope.repo({
123
123
+
collection: ["social.attic.actor.profile"],
124
124
+
}),
125
125
+
];
126
126
+
120
127
let keyset: ClientAssertionPrivateJwk[] | undefined;
121
128
129
129
+
let metadata: OAuthClientOptions["metadata"];
122
130
if (dev) {
123
131
metadata = {
124
132
redirect_uris: [redirect.href],
+31
-2
src/lib/server/session.ts
···
3
3
HANDLE_COOKIE,
4
4
OAUTH_MAX_AGE,
5
5
SESSION_COOKIE,
6
6
+
SESSION_MAX_AGE,
6
7
} from "$lib/server/constants.ts";
7
7
-
import { decryptText } from "$lib/server/crypto.ts";
8
8
+
import { decryptText, encryptText } from "$lib/server/crypto.ts";
8
9
import { createOAuthClient } from "$lib/server/oauth.ts";
9
10
import { parsePublicUser, type PublicUserData } from "$lib/valibot.ts";
10
11
import { Client } from "@atcute/client";
···
31
32
};
32
33
33
34
/**
34
34
-
* Login
35
35
+
* Begin auth flow
35
36
* @returns {URL} OAuth redirect
36
37
*/
37
38
export const startSession = async (
···
58
59
},
59
60
);
60
61
return url;
62
62
+
};
63
63
+
64
64
+
/**
65
65
+
* Store the logged in user data
66
66
+
*/
67
67
+
export const updateSession = async (
68
68
+
event: RequestEvent,
69
69
+
user: PublicUserData,
70
70
+
) => {
71
71
+
const { cookies, platform } = event;
72
72
+
if (platform?.env === undefined) {
73
73
+
throw new Error();
74
74
+
}
75
75
+
const encrypted = await encryptText(
76
76
+
JSON.stringify(user),
77
77
+
platform.env.PRIVATE_COOKIE_KEY,
78
78
+
);
79
79
+
cookies.set(
80
80
+
SESSION_COOKIE,
81
81
+
encrypted,
82
82
+
{
83
83
+
httpOnly: true,
84
84
+
maxAge: SESSION_MAX_AGE,
85
85
+
path: "/",
86
86
+
sameSite: "lax",
87
87
+
secure: !dev,
88
88
+
},
89
89
+
);
61
90
};
62
91
63
92
/**
+18
-1
src/lib/valibot.ts
···
4
4
import { OAuthSession } from "@atcute/oauth-node-client";
5
5
import * as v from "valibot";
6
6
7
7
+
const DisplayNameSchema = v.pipe(
8
8
+
v.string(),
9
9
+
v.trim(),
10
10
+
v.minLength(1),
11
11
+
v.maxLength(640),
12
12
+
v.maxGraphemes(64),
13
13
+
);
14
14
+
7
15
const UserSchema = {
8
16
did: v.custom<Did>(isDid, "invalid did"),
9
17
handle: v.custom<Handle>(isHandle, "invalid handle"),
10
10
-
displayName: v.string(),
18
18
+
displayName: DisplayNameSchema,
11
19
};
12
20
13
21
export const PublicUserSchema = v.object(UserSchema);
···
27
35
export function parsePrivateUser(data: unknown): PrivateUserData {
28
36
return v.parse(PrivateUserSchema, data);
29
37
}
38
38
+
39
39
+
const ActorProfileSchema = v.object({
40
40
+
displayName: DisplayNameSchema,
41
41
+
});
42
42
+
export type ActorProfileData = v.InferOutput<typeof ActorProfileSchema>;
43
43
+
44
44
+
export function parseActorProfile(data: unknown): ActorProfileData {
45
45
+
return v.parse(ActorProfileSchema, data);
46
46
+
}
+56
-1
src/routes/+page.server.ts
···
1
1
import { HANDLE_COOKIE } from "$lib/server/constants.ts";
2
2
-
import { destroySession, startSession } from "$lib/server/session.ts";
2
2
+
import {
3
3
+
destroySession,
4
4
+
startSession,
5
5
+
updateSession,
6
6
+
} from "$lib/server/session.ts";
7
7
+
import { Client } from "@atcute/client";
3
8
import { type Actions, fail, redirect } from "@sveltejs/kit";
9
9
+
import { parseActorProfile } from "../lib/valibot.ts";
10
10
+
4
11
export const actions = {
5
12
logout: async (event) => {
6
13
await destroySession(event);
···
18
25
return fail(400, { handle, action: "login", error: "Invalid handle." });
19
26
}
20
27
redirect(303, url);
28
28
+
},
29
29
+
displayName: async (event) => {
30
30
+
if (event.locals.user === undefined) return;
31
31
+
const { user } = event.locals;
32
32
+
try {
33
33
+
const formData = await event.request.formData();
34
34
+
const record = parseActorProfile({
35
35
+
displayName: formData.get("displayName"),
36
36
+
});
37
37
+
const rpc = new Client({ handler: user.session });
38
38
+
const result = await rpc.post("com.atproto.repo.putRecord", {
39
39
+
input: {
40
40
+
repo: user.did,
41
41
+
collection: "social.attic.actor.profile",
42
42
+
rkey: "self",
43
43
+
record,
44
44
+
},
45
45
+
});
46
46
+
if (result.ok === false) {
47
47
+
throw new Error();
48
48
+
}
49
49
+
event.locals.user.displayName = record.displayName;
50
50
+
await updateSession(event, {
51
51
+
did: user.did,
52
52
+
handle: user.handle,
53
53
+
displayName: record.displayName,
54
54
+
});
55
55
+
return { success: true };
56
56
+
} catch {
57
57
+
return fail(400, { action: "displayName", error: "Failed to update." });
58
58
+
}
59
59
+
},
60
60
+
purge: async (event) => {
61
61
+
const { user } = event.locals;
62
62
+
if (user === undefined) return;
63
63
+
const rpc = new Client({ handler: user.session });
64
64
+
const result = await rpc.post("com.atproto.repo.deleteRecord", {
65
65
+
input: {
66
66
+
repo: user.did,
67
67
+
collection: "social.attic.actor.profile",
68
68
+
rkey: "self",
69
69
+
},
70
70
+
});
71
71
+
if (result.ok) {
72
72
+
await destroySession(event);
73
73
+
redirect(303, "/");
74
74
+
}
75
75
+
return fail(400, { action: "purge", error: "Failed to purge." });
21
76
},
22
77
} satisfies Actions;
+19
-2
src/routes/+page.svelte
···
1
1
<script lang="ts">
2
2
import type { PageProps } from "./$types.d.ts";
3
3
let { data, form }: PageProps = $props();
4
4
+
5
5
+
const confirmPurge = (ev: SubmitEvent) => {
6
6
+
if (confirm("Are you sure?")) {
7
7
+
return;
8
8
+
}
9
9
+
ev.preventDefault();
10
10
+
};
4
11
</script>
5
12
6
13
{#if data.user}
···
8
15
<div class="avatar">
9
16
<img alt="avatar" src="/avatar/{data.user.did}" width="50" height="50" />
10
17
<p>{data.user.displayName}</p>
18
18
+
<p><small>@{data.user.handle}</small></p>
11
19
</div>
12
12
-
13
20
<form method="POST" action="?/displayName">
14
21
<h2>Attic settings</h2>
22
22
+
{#if form?.action === "displayName" && form?.error}
23
23
+
<p class="error">{form.error}</p>
24
24
+
{/if}
15
25
<label for="displayName">Display name</label>
16
26
<input
17
27
type="text"
···
21
31
/>
22
32
<button type="submit">Update</button>
23
33
</form>
24
24
-
25
34
<form method="POST" action="?/logout">
26
35
<h2>Bye!</h2>
27
36
<button type="submit">Sign out</button>
37
37
+
</form>
38
38
+
<form method="POST" action="?/purge" onsubmit={confirmPurge}>
39
39
+
<h2>Purge data</h2>
40
40
+
<p>Delete all Attic records and sign out.</p>
41
41
+
{#if form?.action === "purge" && form?.error}
42
42
+
<p class="error">{form.error}</p>
43
43
+
{/if}
44
44
+
<button type="submit">Confirm</button>
28
45
</form>
29
46
{:else}
30
47
<form method="POST" action="?/login">
+39
-35
src/routes/oauth/callback/+server.ts
···
1
1
-
import { dev } from "$app/environment";
2
2
-
import {
3
3
-
HANDLE_COOKIE,
4
4
-
SESSION_COOKIE,
5
5
-
SESSION_MAX_AGE,
6
6
-
} from "$lib/server/constants.ts";
7
7
-
import { encryptText } from "$lib/server/crypto.ts";
1
1
+
import { HANDLE_COOKIE } from "$lib/server/constants.ts";
8
2
import { createOAuthClient } from "$lib/server/oauth.ts";
9
9
-
import type { PublicUserData } from "$lib/valibot.ts";
3
3
+
import { parseActorProfile, type PublicUserData } from "$lib/valibot.ts";
10
4
import { AppBskyActorGetProfile } from "@atcute/bluesky";
11
5
import { Client, ok } from "@atcute/client";
12
6
import { isHandle } from "@atcute/lexicons/syntax";
13
7
import type { OAuthSession } from "@atcute/oauth-node-client";
14
8
import { redirect } from "@sveltejs/kit";
9
9
+
import { updateSession } from "../../../lib/server/session.ts";
15
10
import type { RequestHandler } from "./$types.d.ts";
16
11
17
12
export const GET: RequestHandler = async (event) => {
···
35
30
redirect(303, "/?error=session");
36
31
}
37
32
38
38
-
const data: PublicUserData = {
33
33
+
const user: PublicUserData = {
39
34
handle,
40
35
did: session.did,
41
36
displayName: handle,
42
37
};
38
38
+
39
39
+
const rpc = new Client({ handler: session });
43
40
44
41
try {
45
45
-
const rpc = new Client({ handler: session });
46
46
-
const profile = await ok(
47
47
-
rpc.call(AppBskyActorGetProfile, {
48
48
-
params: { actor: session.did },
49
49
-
}),
50
50
-
);
51
51
-
if (profile.displayName) {
52
52
-
data.displayName = profile.displayName;
53
53
-
}
42
42
+
const result = await ok(rpc.get("com.atproto.repo.getRecord", {
43
43
+
params: {
44
44
+
repo: user.did,
45
45
+
collection: "social.attic.actor.profile",
46
46
+
rkey: "self",
47
47
+
},
48
48
+
}));
49
49
+
const profile = parseActorProfile(result.value);
50
50
+
user.displayName = profile.displayName;
54
51
} catch {
55
55
-
// No Bluesky account?
52
52
+
try {
53
53
+
await ok(rpc.call(AppBskyActorGetProfile, {
54
54
+
params: { actor: user.did },
55
55
+
})).then((result) => {
56
56
+
if (result.displayName) {
57
57
+
user.displayName = result.displayName;
58
58
+
}
59
59
+
}).catch(() => {/* No Bluesky */});
60
60
+
const create = await rpc.post("com.atproto.repo.putRecord", {
61
61
+
input: {
62
62
+
repo: user.did,
63
63
+
collection: "social.attic.actor.profile",
64
64
+
rkey: "self",
65
65
+
record: { displayName: user.displayName },
66
66
+
},
67
67
+
});
68
68
+
if (create.ok === false) {
69
69
+
throw new Error();
70
70
+
}
71
71
+
} catch (err) {
72
72
+
console.log(err);
73
73
+
redirect(303, "/?fail");
74
74
+
}
56
75
}
57
76
58
58
-
const encrypted = await encryptText(
59
59
-
JSON.stringify(data),
60
60
-
platform.env.PRIVATE_COOKIE_KEY,
61
61
-
);
62
62
-
63
63
-
cookies.set(
64
64
-
SESSION_COOKIE,
65
65
-
encrypted,
66
66
-
{
67
67
-
httpOnly: true,
68
68
-
maxAge: SESSION_MAX_AGE,
69
69
-
path: "/",
70
70
-
sameSite: "lax",
71
71
-
secure: !dev,
72
72
-
},
73
73
-
);
77
77
+
await updateSession(event, user);
74
78
75
79
redirect(303, "/");
76
80
};
+1
-1
tsconfig.json
···
11
11
"sourceMap": true,
12
12
"strict": true,
13
13
"moduleResolution": "bundler",
14
14
-
"types": [],
14
14
+
"types": ["@atcute/atproto", "@atcute/bluesky"],
15
15
},
16
16
}