~leah/basingstoke

2195dc2a6d9db92184c4b65c03d81dfc3ad62bb5 — leah 3 months ago 0acf7aa
yeah
54 files changed, 893 insertions(+), 251 deletions(-)

A bonus/federation.md
A bonus/innit/basingstoke.dinit
R src/lib/Forms/FormItem.svelte => bonus/innit/basingstoke.service
A bonus/setup.md
D cache_schema.sql
M config.stoke.example.toml
R blob_schema.sql => data/blob-schema.sql
A data/cache-schema.sql
A data/innit.sh
A data/reset.sh
R schema.sql => data/schema.sql
M src/lib/ActivityPub/actor.ts
A src/lib/ActivityPub/caching.ts
M src/lib/ActivityPub/index.ts
M src/lib/ActivityPub/model.ts
A src/lib/ActivityPub/signing.ts
A src/lib/ActivityPub/webfinger/fetch.ts
A src/lib/ActivityPub/webfinger/index.ts
M src/lib/Bonus/ContextMenu.svelte
M src/lib/Bonus/Menu.svelte
M src/lib/Bonus/PrefsFooter.svelte
M src/lib/Bonus/SensibleMenu.svelte
A src/lib/Bonus/WarningBox.svelte
M src/lib/Colours/colours.json
M src/lib/Colours/index.ts
A src/lib/Forms/FoldingThing.svelte
M src/lib/Forms/FormInput.svelte
M src/lib/Media/index.ts
M src/lib/Navigation/PrefsMenu.svelte
R src/lib/Pings/{HeadsUp.svelte => Toast.svelte}
R src/lib/Pings/{HeadsUpses.svelte => ToastList.svelte}
M src/lib/Pings/index.ts
M src/lib/config.ts
M src/lib/server/Auth/keys.ts
M src/lib/server/Model/userpage.ts
M src/lib/server/db.ts
M src/routes/(guarded)/prefs/+page.svelte
M src/routes/(guarded)/prefs/appearance/ColourPicker.svelte
A src/routes/(guarded)/prefs/cliques/+page.svelte
M src/routes/(guarded)/prefs/profile/+page.server.ts
M src/routes/+layout.svelte
M src/routes/Footer.svelte
M src/routes/admin/instance/+page.svelte
M src/routes/its/[username]/+page.svelte
M src/routes/its/[username]/Filters.svelte
M src/routes/its/[username]/HeaderImage.svelte
M src/routes/its/[username]/Metadata.svelte
M src/routes/its/[username]/MetadataTable.svelte
M src/routes/its/[username]/ProfileHeader.svelte
A src/routes/its/[username]/at/[server]/+page.server.ts
A src/routes/its/[username]/at/[server]/+page.svelte
M src/routes/its/[username]/follow/+page.svelte
M src/routes/testpage/+page.svelte
M src/routes/user/[username]/+server.ts
A bonus/federation.md => bonus/federation.md +80 -0
@@ 0,0 1,80 @@
# federating with basingstoke

basingstoke is mainly concerned with federating with itself. following 
basingstoke users from other platforms (like Mastodon) will work, but some 
things might look a bit weird.

## json ld

basingstoke doesn't bother with anything JSON-LD, although an effort has been 
made for compatibility with servers that do (assuming they exist).

```
{
    "@context": ["https://www.w3.org/ns/activitystreams", {
        "stoke": "https://ns.pronounmail.com"
    }]
}
```

## actors


## posts

basingstoke accepts the following object types:

* `Article`
* `Note`
* `Page`
* `Question`

### reposts

reposts are identified by a `RE: <post link>` line at the bottom of a post's 
content. basingstoke also includes a `Link` containing a link to the previous 
post in the repost chain; This is the preferred method of parsing reposts.

```json
{
  "@context": ["https://www.w3.org/ns/activitystreams", {
    "stoke": "https://ns.pronounmail.com"
  }],
  "id": "https://basingstoke.example/post/abcd",
  "type": "Note",
  "content": "cool post!\nRE: https://basingstoke.example/post/asdf",
  "tag": [
    {
        "type": "Link",
        "href": "https://basingstoke.example/post/asdf",
        "rel": "prev"
    }
  ]
}
```

### tags

### content warnings

#### common CWs

to make filtering/auto-unfurling common content warnings easier, basingstoke
defines some common content warning names:

* `18+`: Content is suitable for adults only

## securitie

all incoming requests are required to have a valid HTTP Signature. 
both RSA-SHA256 and Ed25519 are supported (although the latter is greatly 
preferred!).

basingstoke signs all outgoing requests (apart from WebFinger requests) with a 
HTTP Signature. unlike other implementations, basingstoke assigns a unique key 
to each user page when it's created.

## migration

an instance migration system is in progress. it'll be a bit different than
Mastodon's.
\ No newline at end of file

A bonus/innit/basingstoke.dinit => bonus/innit/basingstoke.dinit +6 -0
@@ 0,0 1,6 @@
type = process
command = /usr/bin/node /opt/basingstoke/index.js --config /etc/basingstoke/config.toml
logfile = /var/log/stoke.log
smooth-recovery = true
restart = false
depends-ms = networking
\ No newline at end of file

R src/lib/Forms/FormItem.svelte => bonus/innit/basingstoke.service +0 -0
A bonus/setup.md => bonus/setup.md +46 -0
@@ 0,0 1,46 @@
# setting up a basingstoke

## prerequisites

## downloading

### packaged by your distro

todo

### from the source

assuming you have node.js, git and sqlite3 installed, you should be able to clone
the repository, `npm install` and then `npm run build`:

```bash
git clone https://git.sr.ht/~leah/basingstoke && cd basingstoke/
npm install
npm run build
```

if everything goes well, you should end up with a new build/ directory in the
basingstoke directory. you can copy this wherever and run `node index.js` inside 
it to run the server.

```bash
sudo mv build /opt/basingstoke

```

## configuration



## make it a service

it's generally a good idea to create a service using your system's init system
(usually systemd or openrc) to keep the server running across reboots and crashes.

sample files are available in the `bonus/` directory:

| innit system                            | where                             |
| --------------------------------------- | --------------------------------- |
| **systemd**                             | `bonus/innit/basingstoke.service` |
| **openrc**                              | todo                              |
| **dinit** (trying to future proof here) | `bonus/innit/basingstoke.dinit`   |
\ No newline at end of file

D cache_schema.sql => cache_schema.sql +0 -11
@@ 1,11 0,0 @@
CREATE TABLE IF NOT EXISTS cached (
    key TEXT PRIMARY KEY UNIQUE NOT NULL,
    value BLOB,
    created INTEGER NOT NULL DEFAULT (unixepoch(CURRENT_TIMESTAMP))
    ttl INTEGER NOT NULL DEFAULT 1800
);

CREATE TRIGGER IF NOT EXISTS tidy_up AFTER UPDATE ON cached
BEGIN
    DELETE FROM cached WHERE created < (unixepoch(CURRENT_TIMESTAMP) - ttl)
END;

M config.stoke.example.toml => config.stoke.example.toml +3 -2
@@ 1,4 1,5 @@
[stoke]
hostname = "bazinga.gov"
db_path = "stoke.db"
blob_db_path = "stoke-blob.db"
\ No newline at end of file
db_path = "data/stoke.db"
blob_db_path = "data/stoke-blob.db"
cache_db_path = "data/stoke-cache.db"
\ No newline at end of file

R blob_schema.sql => data/blob-schema.sql +0 -0
A data/cache-schema.sql => data/cache-schema.sql +19 -0
@@ 0,0 1,19 @@
CREATE TABLE IF NOT EXISTS cached (
    key TEXT PRIMARY KEY UNIQUE NOT NULL,
    value BLOB,
    created INTEGER NOT NULL DEFAULT (unixepoch(CURRENT_TIMESTAMP)),
    ttl INTEGER NOT NULL DEFAULT 1800
);

CREATE TABLE IF NOT EXISTS webfinger (
    hostname TEXT NOT NULL,
    resource TEXT NOT NULL,
    response TEXT NOT NULL,
    
    CONSTRAINT name_resource_unique UNIQUE (hostname, resource)
);

-- CREATE TRIGGER IF NOT EXISTS tidy_up AFTER UPDATE ON cached
-- BEGIN
--     DELETE FROM cached WHERE created < (unixepoch(CURRENT_TIMESTAMP) - ttl)
-- END;

A data/innit.sh => data/innit.sh +5 -0
@@ 0,0 1,5 @@
#!/bin/bash

sqlite3 stoke.db '.read schema.sql'
sqlite3 stoke-blob.db '.read blob-schema.sql'
sqlite3 stoke-cache.db '.read cache-schema.sql'
\ No newline at end of file

A data/reset.sh => data/reset.sh +4 -0
@@ 0,0 1,4 @@
#!/bin/bash

rm stoke*.db*
./innit.sh
\ No newline at end of file

R schema.sql => data/schema.sql +12 -7
@@ 32,10 32,12 @@ CREATE TABLE IF NOT EXISTS user_page (

-- separate table from `user_page` so we can SELECT * with reckless abandon
CREATE TABLE IF NOT EXISTS key_pair (
    page_id INTEGER NOT NULL PRIMARY KEY,
    page_id INTEGER NOT NULL,
    kind    TEXT    NOT NULL,
    private TEXT    NOT NULL,
    public  TEXT    NOT NULL,

    CONSTRAINT key_unique UNIQUE (page_id, kind),
    FOREIGN KEY(page_id) REFERENCES user_page(id) ON DELETE CASCADE
);



@@ 121,12 123,15 @@ CREATE TABLE IF NOT EXISTS session (
-- );

CREATE TABLE IF NOT EXISTS follow (
    follower_id INTEGER NOT NULL,
    followee_id INTEGER NOT NULL,
    follower_id TEXT NOT NULL,
    followee_id TEXT NOT NULL,
    created DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,

    CONSTRAINT follow_unique UNIQUE (follower_id, followee_id),
    CONSTRAINT follow_unique UNIQUE (follower_id, followee_id)
);

    FOREIGN KEY(follower_id) REFERENCES user_page(id) ON DELETE CASCADE,
    FOREIGN KEY(followee_id) REFERENCES user_page(id) ON DELETE CASCADE
);
\ No newline at end of file
CREATE TRIGGER IF NOT EXISTS follow_delete_cascade BEFORE DELETE ON user_page
    BEGIN
        DELETE FROM follow 
        WHERE followee_id = OLD.id OR follower_id = OLD.id;
    END;
\ No newline at end of file

M src/lib/ActivityPub/actor.ts => src/lib/ActivityPub/actor.ts +55 -16
@@ 1,8 1,10 @@
import config from '$lib/config';
import { from_markdown_sync } from '$lib/markdown';
import { fetch_keypair, fetch_page, type KeyPairData, type UserPageData } from '$lib/server/Model/userpage';
import { ap_context } from '.';
import { ActivityPubError, ActorType } from './model';
import UserPage, { fetch_page, type KeyPair, type UserPageData } from '$lib/server/Model/userpage';
import { ap_accept, ap_context } from '.';
import { ActivityPubError, ActorType, type Attachment } from './model';
import { signed_fetch } from './signing';
import WebFinger from "./webfinger";

export const id_from_username = (username: string) =>
	`https://${config.hostname}/user/${username}`;


@@ 15,12 17,6 @@ const linkify = (link: string) => `<a
	${link}
</a>`

type Image = {
	type: string,
	mediaType: string,
	url: string,
}

export class Actor {
	type: ActorType;
	id: string;


@@ 36,12 32,12 @@ export class Actor {

	name?: string;
	summary?: string;
	icon?: string;
	image?: Image;
	icon?: Attachment;
	image?: Attachment;
	url: string;
	attachment: Record<string, any>[] = [];

	constructor(stuff: UserPageData, key_pair: KeyPairData) {
	constructor(stuff: UserPageData, key_pair: KeyPair) {
		const {
			username,
			display_name,


@@ 69,7 65,11 @@ export class Actor {

		if (display_name) this.name = display_name;
		if (bio) this.summary = from_markdown_sync(bio);
		if (avatar_uri) this.icon = avatar_uri;
		if (avatar_uri) this.icon = {
			type: "Image",
			mediaType: "image/jpeg", // todo: take a wild guess
			url: avatar_uri,
		};
		if (pronouns) {
			this.attachment.push({
				type: 'PropertyValue',


@@ 94,16 94,55 @@ export class Actor {

	}

	static async from_db(username: string) {
	static async fetch(username: string, host: string) {

	}

	static async fetch_local(username: string) {
		const user = fetch_page(username);
		if (!user) throw Error(ActivityPubError.NoSuchUser);
		const keys = fetch_keypair(user.id);
		const keys = UserPage.fetch_keypair(user.id);
		if (!keys) throw Error(ActivityPubError.MissingKeys);

		return new Actor(user, keys);
	}

	to_object(): string {
	// todo: error handling, everything else
	static async fetch_remote(
		username: string,
		host: string,
		opt?: { as?: string }
	) {
		const key: [string, string] = (() => {
			if (opt?.as) {
				const { private: priv } = UserPage.fetch_keypair({ username: opt?.as }) ?? {};
				if (!priv) throw ActivityPubError.MissingKeys;
				return [`https://${config.hostname}/user/${opt?.as}`, priv]
			} else {
				throw "todo";
				return [`https://${config.hostname}/root`, ""]
			}
		})()

		const wf_res = await WebFinger.fetch(`acct:${username}@${host}`, host);
		if ("err" in wf_res) return;

		const actor_url = WebFinger.extract_actor(wf_res);
		if (!actor_url) return;

		const actor_res = await signed_fetch(key, actor_url, {
			headers: { "Accept": ap_accept }
		});

		if (!actor_res.ok) return {
			status: actor_res.status,
			err: await actor_res.text()
		};

		return await actor_res.json();
	}

	to_ap(): string {
		const json = {
			...ap_context,
			...this,

A src/lib/ActivityPub/caching.ts => src/lib/ActivityPub/caching.ts +0 -0
M src/lib/ActivityPub/index.ts => src/lib/ActivityPub/index.ts +4 -6
@@ 1,17 1,15 @@
import { redirect } from '@sveltejs/kit';

export function check_for_accept_header(request: Request, username: string) {
	// get accept headers
	const accept = request.headers
		.get('accept')
		?.split(',')
		.map((o) => o.trim());
	// redirect if client doesn't understand activitystreams
	if (accept && !accept.includes('application/activity+json')) {
		throw redirect(303, `/its/${username}`);
	}
	
	return accept && accept.includes('application/activity+json');
}

export const ap_accept = "application/activity+json";

export const ap_context = {
	'@context': [
		'https://www.w3.org/ns/activitystreams',

M src/lib/ActivityPub/model.ts => src/lib/ActivityPub/model.ts +7 -2
@@ 1,5 1,3 @@
import { Create } from "./create";

export enum ActivityPubError {
	NoSuchUser = 'No such user',
	MissingKeys = 'Missing keys',


@@ 69,3 67,10 @@ export enum ObjectType {
	Tombstone = 'Tombstone',
	Video = 'Video',
}

export type Attachment = {
	type: string,
	content?: string,
	mediaType?: string,
	url: string,
}
\ No newline at end of file

A src/lib/ActivityPub/signing.ts => src/lib/ActivityPub/signing.ts +43 -0
@@ 0,0 1,43 @@
import Crypto from "node:crypto";

/**
 * 
 * @param key A tuple containing the key owner and private key in PEM format
 * @param params The usual {@link fetch} parameters
 */
export async function signed_fetch(
    [owner, private_key]: [owner: string, private_key: string],
    ...params: Parameters<typeof fetch>
): ReturnType<typeof fetch> {
    const url = params[0] instanceof Request
        ? new URL(params[0].url)
        : new URL(params[0]);

    const req = params[0] instanceof Request
        ? params[0]
        : params[1]

    const method = req?.method ?? "GET"
    const date = new Date(Date.now()).toUTCString();

    const sig_string = `(request-target): ${method.toLowerCase()} ${url.pathname}\n`
        + `host: ${url.hostname}\n`
        + `date: ${date}`;

    const sig = Crypto.sign("sha256", Buffer.from(sig_string), private_key)
        .toString("base64");

    const sig_headers = "(request-target) host date"
    const key_id = owner
    const sig_header = `keyId="${key_id}",headers="${sig_headers}",signature="${sig}"`

    return fetch(url, {
        ...req,
        headers: {
            ...req?.headers,
            'Date': date,
            'Host': url.hostname,
            'Signature': sig_header,
        }
    });
}
\ No newline at end of file

A src/lib/ActivityPub/webfinger/fetch.ts => src/lib/ActivityPub/webfinger/fetch.ts +57 -0
@@ 0,0 1,57 @@
import { cache_db } from "$lib/server/db";

interface WfResponse {
    subject: string,
    aliases: string[],
    links: WfLink[]
}

type WfLink = {
    rel: string,
    type: string,
    href: string,
}

type FetchError = { err: number };

export async function wf_fetch(resource: string, host: string)
    : Promise<WfResponse | FetchError> {
    const cached = fetch_cached(resource, host);
    if (cached) {
        return JSON.parse(cached);
    }

    const res = await fetch(`https://${host}/.well-known/webfinger?resource=${resource}`);
    if (!res.ok) {
        return { err: res.status }
    }
    const json = await res.json();
    
    cache_response(resource, host, JSON.stringify(json));
    return json;
}

const cached_stmt = cache_db.prepare(`
    SELECT response FROM webfinger
    WHERE resource = ? AND hostname = ?
`).pluck();

function fetch_cached(resource: string, host: string) {
    return cached_stmt.get(resource, host) as string | undefined
}

const insert_stmt = cache_db.prepare(`
    INSERT INTO webfinger (resource, hostname, response)
    VALUES (?, ?, ?)
`);

function cache_response(resource: string, host: string, response: string) {
    insert_stmt.run(resource, host, response);
}

export function wf_extract_actor(res: WfResponse): string | undefined {
    return res.links.find(x => (
        x.rel == "self"
        && x.type == "application/activity+json"
    ))?.href;
}
\ No newline at end of file

A src/lib/ActivityPub/webfinger/index.ts => src/lib/ActivityPub/webfinger/index.ts +6 -0
@@ 0,0 1,6 @@
import { wf_extract_actor, wf_fetch } from "./fetch";

export default {
    fetch: wf_fetch,
    extract_actor: wf_extract_actor
}
\ No newline at end of file

M src/lib/Bonus/ContextMenu.svelte => src/lib/Bonus/ContextMenu.svelte +3 -2
@@ 25,6 25,7 @@
            open = true;
        }
    }
    
</script>

<div


@@ 36,8 37,8 @@
    <slot />
</div>

<Menu title="Context Menu" {anchor} bind:trigger bind:xy bind:open>
    <slot name="menu" />
<Menu title="Context Menu" {anchor} bind:trigger bind:xy bind:open let:close>
    <slot name="menu" {close} />
</Menu>

<style>

M src/lib/Bonus/Menu.svelte => src/lib/Bonus/Menu.svelte +2 -2
@@ 150,11 150,11 @@
        <div bind:this={bg} class="bg" aria-hidden></div>
        {#if mode == "freestyle"}
            <div bind:this={content}>
                <slot />
                <slot close={close_menu} />
            </div>
        {:else}
            <ul role="menu" bind:this={content} class="content">
                <slot />
                <slot close={close_menu} />
            </ul>
        {/if}
    </dialog>

M src/lib/Bonus/PrefsFooter.svelte => src/lib/Bonus/PrefsFooter.svelte +1 -1
@@ 58,7 58,7 @@
        scrolled = true;
    }

    onDestroy(() => observer.disconnect());
    onDestroy(() => observer?.disconnect());
</script>

<div bind:this={bg} class="bg" class:scrolled></div>

M src/lib/Bonus/SensibleMenu.svelte => src/lib/Bonus/SensibleMenu.svelte +5 -0
@@ 10,6 10,11 @@
        trigger: HTMLButtonElement;

    const open_menu = (e: MouseEvent) => {
        if (open) {
            open = false;
            return;
        }
        
        xy = [e.clientX, e.clientY];
        trigger = e.target as HTMLButtonElement;
        open = true;

A src/lib/Bonus/WarningBox.svelte => src/lib/Bonus/WarningBox.svelte +44 -0
@@ 0,0 1,44 @@
<script lang="ts">
    import { TriangleExclamation } from "$lib/Icons";
    import Icon from "$lib/Icons/Icon.svelte";

    export let title;
</script>

<section>
    <h2>
        <Icon source={TriangleExclamation} />
        {title}
    </h2>
    <p>
        <slot />
    </p>
</section>

<style>
    section {
        position: relative;
        border: 1px solid var(--foreground);
        padding: 1rem;
        margin: 2rem 0;
    }
    h2 {
        position: absolute;
        top: -0.5rem;
        left: 0.5rem;
        margin: 0;
        padding: 0 0.5rem;
        display: flex;
        gap: 0.5rem;

        font-family: var(--font-sans);
        font-size: 0.9rem;
        font-weight: 500;
        text-transform: uppercase;

        background-color: var(--background);
    }
    p {
        margin: 0;
    }
</style>

M src/lib/Colours/colours.json => src/lib/Colours/colours.json +3 -1
@@ 12,5 12,7 @@
  ["foliage-y", "#F2FAF0", "#2F8C74"],
  ["quiet", "#E7F6DA", "#7C5DB4"],
  ["parma violet", "#E7E4FB", "#490DF4"],
  ["charcoal", "#021F2C", "#808FA6"]
  ["charcoal", "#021F2C", "#808FA6"],
  ["transgender (couldt'n fit the white in sorry", "#0000FF", "#FF00FF"],
  [") hot dog stand", "#FFFF00", "#FF0000"]
]

M src/lib/Colours/index.ts => src/lib/Colours/index.ts +6 -0
@@ 30,3 30,9 @@ export function set_colours(set: ColourSet) {
	localStorage.setItem(local_storage_key, JSON.stringify(set));
}

export const is_selected = (current: ColourSet, colour_set: ColourSet) =>
	current.includes(colour_set[1]) &&
	current.includes(colour_set[2]);

export const is_selected_strict = (current: ColourSet, colour_set: ColourSet) =>
	current[1] == colour_set[1] && current[2] == colour_set[2];

A src/lib/Forms/FoldingThing.svelte => src/lib/Forms/FoldingThing.svelte +67 -0
@@ 0,0 1,67 @@
<script lang="ts">
    import { CaretDown, CaretUp } from "$lib/Icons";
    import Icon from "$lib/Icons/Icon.svelte";

    export let title: string,
        subtitle: string | null = null;
</script>

<details {...$$restProps}>
    <summary>
        <div>
            <h2>{title}</h2>
            {#if subtitle}
                <small>{subtitle}</small>
            {/if}
        </div>
        <Icon class="down" source={CaretDown} />
        <Icon class="up" source={CaretUp} />
    </summary>
    <slot />
</details>

<style>
    details {
        padding: .75rem 0.5rem;

        & > summary {
            display: flex;
            align-items: center;
            justify-content: space-between;

            list-style: none;
            
            user-select: none;
        }

        &:has(> summary:hover) {
                background-color: var(--background-tone);
        }

        & > summary > .up {
            display: none;
        }

        &[open] > summary {
            margin-bottom: 1rem;

            & > .up {
                display: block;
            }
            & > .down {
                display: none;
            }
        }

        & + details {
            border-top: 1px solid var(--foreground-tone);
        }
    }

    h2 {
        font-family: var(--font-sans);
        font-weight: 700;
        font-size: 1rem;
        margin: 0;
    }
</style>

M src/lib/Forms/FormInput.svelte => src/lib/Forms/FormInput.svelte +1 -1
@@ 3,7 3,7 @@
        flavour_text: string | undefined = undefined;
</script>

<label>
<label {...$$restProps}>
    <span>{label}</span>
    <slot />
    {#if flavour_text}

M src/lib/Media/index.ts => src/lib/Media/index.ts +31 -27
@@ 2,38 2,42 @@ import { blob_db } from "$lib/server/db"
import { randomUUID } from "crypto";
import config from "$lib/config";

export default class Media {
    static store_stmt = blob_db.prepare(`
        INSERT INTO blob (id, content_type, bytes) 
        VALUES (@id, @content_type, @bytes);
    `);
const store_stmt = blob_db.prepare(`
    INSERT INTO blob (id, content_type, bytes) 
    VALUES (@id, @content_type, @bytes);
`);

function store(content_type: string, bytes: Uint8Array | Buffer) {
    if (bytes instanceof Buffer) {
        bytes = Uint8Array.from(bytes)
    }

    static store(content_type: string, bytes: Uint8Array | Buffer) {
        if (bytes instanceof Buffer) {
            bytes = Uint8Array.from(bytes)
        }
    const id = randomUUID()
    store_stmt.run({ id, content_type, bytes });

        const id = randomUUID()
        this.store_stmt.run({ id, content_type, bytes });
    return id;
}

        return id;
    }
const retrieve_stmt = blob_db.prepare(`
    SELECT content_type, bytes FROM blob WHERE id = @id;
`);

    static retrieve_stmt = blob_db.prepare(`
        SELECT content_type, bytes FROM blob WHERE id = @id;
    `);
function retrieve(id: string): [string, Buffer] | null | undefined {
    console.time("retrieve")
    const file = retrieve_stmt.get({ id }) as {
        content_type: string,
        bytes: Buffer,
    } | null;
    if (!file) return;
    console.timeEnd("retrieve")

    static retrieve(id: string): [string, Buffer] | null | undefined {
        console.time("retrieve")
        const file = this.retrieve_stmt.get({ id })  as {
            content_type: string,
            bytes: Buffer,
        } | null;
        if (!file) return;
        console.timeEnd("retrieve")
    return [file.content_type, file.bytes];
}

        return [file.content_type, file.bytes];
    }
const resolve = (id: string) => `https://${config.hostname}/media/${id}`

    static get_uri_of = (id: string) => `https://${config.hostname}/media/${id}`
export default {
    resolve,
    store,
    retrieve,
}
\ No newline at end of file

M src/lib/Navigation/PrefsMenu.svelte => src/lib/Navigation/PrefsMenu.svelte +63 -13
@@ 2,12 2,16 @@
    import { goto } from "$app/navigation";
    import ContextMenu from "$lib/Bonus/ContextMenu.svelte";
    import Item from "$lib/Bonus/MenuItem.svelte";
    import { colours } from "$lib/Colours";
    import {
        colour_store,
        colours,
        is_selected,
        update_colours,
    } from "$lib/Colours";
    import {
        Accessibility,
        Bell,
        CircleUser,
        Cog,
        Cube,
        Hand,
        HeadSide,


@@ 15,23 19,52 @@
        PencilSquare,
        Rss,
        Swatches,
        User,
        Users,
    } from "$lib/Icons";
    import * as Stores from "svelte/store";

    export let username: string, email: string;
</script>

<ContextMenu anchor="right">
    <slot />
    <svelte:fragment slot="menu">
        <ul class="colours">
    <svelte:fragment slot="menu" let:close>
        <ul class="colours" aria-label="colour scheme picker">
            {#each colours.slice(0, 6) as colour}
                <li class="colour">
                    <div style="--colour: {colour[1]}" />
                    <div style="--colour: {colour[2]}" />
                <li>
                    <button
                        on:click={() => {
                            update_colours(colour);
                            close();
                        }}
                        type="button"
                        class:selected={is_selected(
                            // just take a snapshot of the store because
                            // otherwise selecting the theme makes the menu infinitely
                            // shift to the left
                            Stores.get(colour_store),
                            colour,
                        )}
                        title={colour[0]}
                        class="colour"
                    >
                        <div style="--colour: {colour[1]}" />
                        <div style="--colour: {colour[2]}" />
                    </button>
                </li>
            {/each}
            <li class="colour more">-></li>
            <li>
                <button
                    on:click={() => {
                        goto("/prefs/appearance");
                        close();
                    }}
                    class="colour more"
                    title="appearance preferences"
                >
                    ->
                </button>
            </li>
        </ul>
        <h3>@{username}</h3>
        <Item on:click={() => goto("/prefs/profile")} icon={CircleUser}>


@@ 43,6 76,9 @@
        <Item on:click={() => goto("/prefs/syndication")} icon={Rss}>
            Syndication
        </Item>
        <Item on:click={() => goto("/prefs/syndication")} icon={Users}>
            Cliques
        </Item>
        <h3>{email}</h3>
        <Item on:click={() => goto("/prefs/account")} icon={List}>Details</Item>
        <Item on:click={() => goto("/prefs/appearance")} icon={Swatches}>


@@ 54,11 90,10 @@
        >
            Accessibility
        </Item>
        <Item on:click={() => goto("/prefs/wellbeing")} icon={HeadSide}>
            Wellbeing
        </Item>
        <Item on:click={() => goto("/prefs/pings")} icon={Bell}>Pings</Item>
        <Item on:click={() => goto("/prefs/apps")} icon={Cube}>Applications</Item>
        <Item on:click={() => goto("/prefs/apps")} icon={Cube}
            >Applications</Item
        >
        <Item on:click={() => goto("/prefs/safety")} icon={Hand}>Safety</Item>
    </svelte:fragment>
</ContextMenu>


@@ 77,10 112,19 @@
        min-width: 1.5rem;
        height: 1.5rem;
        aspect-ratio: 1 / 1;
    }

    li > button {
        min-width: 1.5rem;
        height: 1.5rem;
        aspect-ratio: 1 / 1;
        margin: 0;
        padding: 0;
        display: grid;
        border-radius: 2px;
        overflow: hidden;
        grid-template-columns: 1fr 1fr;
        cursor: unset;
    }

    div {


@@ 96,5 140,11 @@
            var(--foreground) 15%,
            transparent
        );
        color: var(--foreground);
    }

    .selected {
        outline: 2px solid
            color-mix(in srgb, var(--foreground), var(--background) 50%);
    }
</style>

R src/lib/Pings/HeadsUp.svelte => src/lib/Pings/Toast.svelte +3 -3
@@ 1,11 1,11 @@
<script lang="ts">
    import { stagger, timeline, type Easing } from "motion";
    import { onMount } from "svelte";
    import { dismiss_heads_up, type HeadsUp } from ".";
    import { dismiss_toast, type Toast } from ".";
    import Icon from "$lib/Icons/Icon.svelte";
    import { Close } from "$lib/Icons";

    export let data: HeadsUp;
    export let data: Toast;

    let bg: HTMLDivElement, content: HTMLDivElement, buttons: HTMLUListElement;



@@ 76,7 76,7 @@

    async function close() {
        await out().finished;
        dismiss_heads_up(data.id);
        dismiss_toast(data.id);
    }
</script>


R src/lib/Pings/HeadsUpses.svelte => src/lib/Pings/ToastList.svelte +39 -32
@@ 1,48 1,56 @@
<script lang="ts">
    import { afterUpdate } from "svelte";
    import { heads_ups } from ".";
    import HeadsUp from "./HeadsUp.svelte";
    import { visible_toasts } from ".";
    import Toast from "./Toast.svelte";
    import { animate } from "motion";

    let bg: HTMLDivElement,
        anim_done: boolean = false;
        bg_visible: boolean = false;

    afterUpdate(() => {
        if ($heads_ups.length != 0 && !anim_done) {
            anim_done = true;
            animate(
                bg,
                {
                    opacity: [0, 1],
                    filter: ["blur(0px)", "blur(50px)"],
                },
                { duration: 0.5 },
            );
        }
        if ($visible_toasts.length != 0 && !bg_visible) first_toast_added();

        if ($heads_ups.length == 0) {
            console.log("here");
            anim_done = false;
            animate(
                bg,
                {
                    opacity: [1, 0],
                },
                {
                    duration: 0.25,
                    easing: "ease-in-out",
                },
            );
        }
        if ($visible_toasts.length == 0) all_toasts_dismissed();
    });

    function first_toast_added() {
        bg_visible = true;
        animate(
            bg,
            {
                opacity: [0, 1],
                filter: ["blur(0px)", "blur(50px)"],
            },
            { duration: 0.5 },
        )
        .finished.then(() => bg.style.height = bg.clientHeight + "px");
    }

    function toast_added() {

    }

    function all_toasts_dismissed() {
        bg_visible = false;
        animate(
            bg,
            {
                opacity: [1, 0],
            },
            {
                duration: 0.25,
                easing: "ease-in-out",
            },
        );
    }
</script>

<div class="container">
    <div class="bg" bind:this={bg} aria-hidden />
    {#if $heads_ups.length != 0}
    {#if $visible_toasts.length != 0}
        <div class="scrolly">
            {#each $heads_ups as thing (thing.id)}
                <HeadsUp data={thing} />
            {#each $visible_toasts as thing (thing.id)}
                <Toast data={thing} />
            {/each}
        </div>
    {/if}


@@ 79,7 87,6 @@
        border-radius: 200px;
        padding: 100%;
        width: 100%;
        height: 100%;
        z-index: -1;
    }
</style>

M src/lib/Pings/index.ts => src/lib/Pings/index.ts +6 -6
@@ 7,7 7,7 @@ export enum PingType {
    Mention,
}

export interface HeadsUp {
export interface Toast {
    id: string,
    occasion?: [icon: any, name: string],
    text: string,


@@ 15,13 15,13 @@ export interface HeadsUp {
    noisy: boolean,
}

export const heads_ups = writable<HeadsUp[]>([]);
export const visible_toasts = writable<Toast[]>([]);

export function show_heads_up(thing: Omit<HeadsUp, "id">) {
export function show_toast(thing: Omit<Toast, "id">) {
    const id = window.crypto.randomUUID();
    heads_ups.update(val => [...val, { ...thing, id }]);
    visible_toasts.update(val => [...val, { ...thing, id }]);
}

export function dismiss_heads_up(id: string) {
    heads_ups.update(val => val.filter(o => o.id != id));
export function dismiss_toast(id: string) {
    visible_toasts.update(val => val.filter(o => o.id != id));
}
\ No newline at end of file

M src/lib/config.ts => src/lib/config.ts +1 -0
@@ 9,6 9,7 @@ export const config_schema = z.object({
	hostname: z.string(),
	db_path: z.string(),
	blob_db_path: z.string(),
	cache_db_path: z.string(),
});

export const config = config_schema.parse(data);

M src/lib/server/Auth/keys.ts => src/lib/server/Auth/keys.ts +19 -19
@@ 1,22 1,22 @@
import { generateKeyPair } from 'crypto';
import util from "node:util";

export async function make_keys(): Promise<{ publicKey: string, privateKey: string }> {
    const p: Promise<{ publicKey: string, privateKey: string }> = new Promise((resolve, reject) => {
        generateKeyPair('rsa', {
            modulusLength: 2048,
            publicKeyEncoding: {
                type: 'spki',
                format: 'pem'
            },
            privateKeyEncoding: {
                type: 'pkcs8',
                format: 'pem'
            }
        }, (err: Error | null, publicKey: string, privateKey: string) => {
            if (err) throw err
            resolve({ publicKey, privateKey })
        })
    });
const p_generateKeyPair = util.promisify(generateKeyPair)
const key_opts = {
    modulusLength: 2048,
    publicKeyEncoding: {
        type: 'spki',
        format: 'pem'
    },
    privateKeyEncoding: {
        type: 'pkcs8',
        format: 'pem'
    }
}

    return p;
}
\ No newline at end of file
export async function make_keys() {
    const rsa = await p_generateKeyPair('rsa', key_opts);
    const ed25519 = await p_generateKeyPair("ed25519", key_opts);

    return { rsa, ed25519 };
}

M src/lib/server/Model/userpage.ts => src/lib/server/Model/userpage.ts +53 -9
@@ 1,6 1,17 @@
import type { Actor } from '$lib/ActivityPub/actor';
import { make_keys } from '$lib/server/Auth/keys';
import { db } from '$lib/server/db';

export type UserPage = InternalPage | ExternalPage;

export interface InternalPage extends UserPageData {

}

export interface ExternalPage extends UserPageData {
	hostname: string,
}

export interface UserPageData {
	id: number
	username: string


@@ 13,7 24,8 @@ export interface UserPageData {
	display_name?: string
	bio?: string
	pronouns?: string
	homepage?: string
	homepage?: string,
	bonus_metadata?: Map<string, string>,

	avatar_uri?: string
	banner_uri?: string


@@ 41,8 53,8 @@ const last_inserted = db.prepare(`
`).pluck();

const insert_keypair = db.prepare(`
	INSERT INTO key_pair (page_id, public, private) 
	VALUES (@id, @publicKey, @privateKey);
	INSERT INTO key_pair (page_id, kind, public, private) 
	VALUES (@id, @kind, @publicKey, @privateKey);
`);

export async function create_page(props: {


@@ 54,8 66,9 @@ export async function create_page(props: {
	db.transaction(() => {
		create_stmt.run(props);
		const id = last_inserted.run().lastInsertRowid;
		console.log(id);
		insert_keypair.run({ ...keys, id });

		insert_keypair.run({ ...keys.rsa, kind: "rsa", id });
		insert_keypair.run({ ...keys.ed25519, kind: "ed25519", id });
	})();
}



@@ 68,7 81,7 @@ export function fetch_pages_belonging_to(id: number) {
	return fetch_pages_stmt.all(id) as UserPageData[] | undefined;
}

export interface KeyPairData {
export interface KeyPair {
	public: string,
	private: string,
	page_id: number,


@@ 76,9 89,40 @@ export interface KeyPairData {

const keypair_stmt = db.prepare(`
	SELECT * FROM key_pair
	WHERE page_id = ?;
	WHERE page_id = ? AND kind = 'rsa';
`)

const keypair_username_stmt = db.prepare(`
	SELECT * 
	FROM user_page, key_pair
	WHERE user_page.username = ? AND page_id = user_page.id AND kind = 'rsa';
`)

export function fetch_keypair(page_id: number) {
	return keypair_stmt.get(page_id) as KeyPairData | undefined;
const fetch_keypair = (page_id: number | { username: string }) => (
	typeof page_id == "number"
		? keypair_stmt.get(page_id) as KeyPair | undefined
		: keypair_username_stmt.get(page_id.username) as KeyPair | undefined
)

const from_actor = (actor: Actor): ExternalPage => ({
	id: 0, // todo
	contributors: [],
	hostname: new URL(actor.id).hostname,
	username: actor.preferredUsername,
	avatar_uri: actor.icon?.url,
	bio: actor.summary,
	display_name: actor.name,
	// yeah that'll do
	bonus_metadata: <Map<string, string>>actor.attachment?.reduce((acc, x) => {
		if (x.type == "PropertyValue") acc.set(x.name, x.value);
		return acc;
	}, new Map()),
	followers: [],
	following: [],
	public_key: ''
})

export default {
	fetch_keypair,
	from_actor
}
\ No newline at end of file

M src/lib/server/db.ts => src/lib/server/db.ts +5 -8
@@ 26,15 26,12 @@ class DebugDatabase extends Database {
	}
}

export const db = building
	? new DebugDatabase(config.db_path)
	: new Database(config.db_path);
export const [db, blob_db, cache_db]
	= [config.db_path, config.blob_db_path, config.cache_db_path].map(
		x => building ? new DebugDatabase(x) : new Database(x)
	);

export const blob_db = building
	? new DebugDatabase(config.blob_db_path)
	: new Database(config.blob_db_path);

[db, blob_db].forEach(d => {
[db, blob_db, cache_db].forEach(d => {
	d.pragma('journal_mode = WAL   ');
	d.pragma('synchronous  = NORMAL');
	d.pragma('foreign_keys = ON    ');

M src/routes/(guarded)/prefs/+page.svelte => src/routes/(guarded)/prefs/+page.svelte +1 -1
@@ 16,6 16,7 @@
	<li><a class="big-button" href="/prefs/profile">profile</a></li>
	<li><a class="big-button" href="/prefs/stationery">stationery</a></li>
	<li><a class="big-button" href="/prefs/syndication">syndication</a></li>
	<li><a class="big-button" href="/prefs/cliques">cliques</a></li>
</ul>

<h3>


@@ 26,7 27,6 @@
	<li><a class="big-button" href="/prefs/account">details</a></li>
	<li><a class="big-button" href="/prefs/appearance">appearance</a></li>
	<li><a class="big-button" href="/prefs/accessibility">accessibility</a></li>
	<li><a class="big-button" href="/prefs/wellbeing">wellbeing</a></li>
	<li><a class="big-button" href="/prefs/pings">pings</a></li>
	<li><a class="big-button" href="/prefs/safety">safety</a></li>
</ul>

M src/routes/(guarded)/prefs/appearance/ColourPicker.svelte => src/routes/(guarded)/prefs/appearance/ColourPicker.svelte +13 -16
@@ 1,17 1,11 @@
<script lang="ts">
	import {
		colour_store,
		colours,
		update_colours,
		type ColourSet,
	    colour_store,
	    colours,
	    is_selected,
	    is_selected_strict,
	    update_colours
	} from "$lib/Colours";

	const is_selected = (colour_set: ColourSet) =>
		$colour_store.includes(colour_set[1]) &&
		$colour_store.includes(colour_set[2]);

	const is_selected_strict = (colour_set: ColourSet) =>
		$colour_store[1] == colour_set[1] && $colour_store[2] == colour_set[2];
</script>

<ul>


@@ 25,10 19,13 @@
				--set-foreground-tone: color-mix(in srgb, {colour_set[2]}, {colour_set[1]} 10%);"
			>
				<button
					class:selected={is_selected(colour_set)}
					class:selected={is_selected($colour_store, colour_set)}
					on:click={() =>
						update_colours(colour_set, {
							reverse: is_selected_strict(colour_set),
							reverse: is_selected_strict(
								$colour_store,
								colour_set,
							),
						})}
				>
					<div class="foreground" />


@@ 54,7 51,7 @@
	button {
		padding: 0;

		height: 6rem;
		height: 5.8rem;
		aspect-ratio: 1/1;

		display: flex;


@@ 68,12 65,12 @@

	.foreground {
		background-color: var(--set-foreground);
		height: 6rem;
		height: inherit;
		aspect-ratio: 1/2;
	}
	.background {
		background-color: var(--set-background);
		height: 6rem;
		height: inherit;
		aspect-ratio: 1/2;
	}


A src/routes/(guarded)/prefs/cliques/+page.svelte => src/routes/(guarded)/prefs/cliques/+page.svelte +22 -0
@@ 0,0 1,22 @@
<script>
    import WarningBox from "$lib/Bonus/WarningBox.svelte";
    import Header from "$lib/Navigation/Header.svelte";
</script>

<Header parents={[["preferences", "/prefs"]]}>cliques</Header>

<p>
    Cliques allow you to privately send posts to a limited audience of people.
</p>

<WarningBox title="Privacy Notice">
    While messages to cliques are encryped and invisible to outside users, they
    can still be reported by clique members. Moderators will be able to view
    reported posts in plain text. Don't post any sensitive information!
</WarningBox>

<WarningBox title="Alpha Warning">
    Basingstoke alpha-quality software. A massive unnoticed security
    vulnerability is entirely feasible here. Seriously, don't post anything
    sensitive!!
</WarningBox>

M src/routes/(guarded)/prefs/profile/+page.server.ts => src/routes/(guarded)/prefs/profile/+page.server.ts +2 -2
@@ 53,7 53,7 @@ async function process_banner(banner: File) {
	const id = Media.store("image/webp", image);
	console.log(id)

	return Media.get_uri_of(id);
	return Media.resolve(id);
}

async function process_avatar(avatar: File) {


@@ 61,5 61,5 @@ async function process_avatar(avatar: File) {
	const id = Media.store("image/webp", buf);
	console.log(id)

	return Media.get_uri_of(id);
	return Media.resolve(id);
}
\ No newline at end of file

M src/routes/+layout.svelte => src/routes/+layout.svelte +3 -3
@@ 15,7 15,7 @@
	import DevHeader from "$lib/Bonus/DevHeader.svelte";
	import { themed_favicon } from "$lib/Bonus/favicon";
	import { setContext } from "svelte";
	import HeadsUpses from "$lib/Pings/HeadsUpses.svelte";
	import ToastList from "$lib/Pings/ToastList.svelte";
	import Footer from "./Footer.svelte";

	export let data;


@@ 27,7 27,7 @@
	$: style = `
	--foreground-color: ${$colour_store[2] ?? "black"};
	--background-color: ${$colour_store[1] ?? "white"};
	--foreground-tone: color-mix(in srgb, ${$colour_store[2]}, ${$colour_store[1]} 10%);
	--foreground-tone: color-mix(in srgb, ${$colour_store[2]}, ${$colour_store[1]} 50%);
	--background-tone: color-mix(in srgb, ${$colour_store[1]}, ${$colour_store[2]} 10%);
	`;



@@ 63,7 63,7 @@
		/>
	{/if}
	<main class:meta id="content"><slot /></main>
	<HeadsUpses />
	<ToastList />
	<Footer wisdom={data.wisdom} />
</div>


M src/routes/Footer.svelte => src/routes/Footer.svelte +2 -1
@@ 66,13 66,14 @@
        grid-column: 1 / span 2;
        background: linear-gradient(
            to bottom,
            var(--background),
            transparent,
            color-mix(in srgb, var(--foreground) 15%, transparent)
        );
        margin-left: -1rem;
        margin-right: -1rem;
        margin-top: -4rem;
        padding: 4rem 0;
        z-index: 1;
    }

    .inner {

M src/routes/admin/instance/+page.svelte => src/routes/admin/instance/+page.svelte +85 -7
@@ 1,5 1,9 @@
<script>
	import Header from '$lib/Navigation/Header.svelte';
	import PrefsFooter from "$lib/Bonus/PrefsFooter.svelte";
	import Button from "$lib/Buttons/Button.svelte";
	import FoldingThing from "$lib/Forms/FoldingThing.svelte";
	import FormInput from "$lib/Forms/FormInput.svelte";
	import Header from "$lib/Navigation/Header.svelte";

	export let data, form;
	let {


@@ 12,12 16,9 @@
	<title>Instance Settings – Basingstoke</title>
</svelte:head>

<Header parents={[['admin', '/admin']]}>instance</Header>
<Header parents={[["admin", "/admin"]]}>instance</Header>

<p>
	These details will be presented to users of this instance, as well as other
	instances looking to federate with this one. Make sure they're accurate.
</p>
<p>some details about your instance and its general vibe</p>

{#if form?.success}
	<p role="status">Saved!</p>


@@ 31,7 32,67 @@
	<p role="alert">{form.error}</p>
{/if}

<form class="bz-form" method="post" style="margin-top: 2rem;">
<form method="post" class="grid">
	<FormInput
		label="hostname"
		flavour_text="you probably shouldn't change this. 
		if you need to, set it in the configuration file"
	>
		<input type="text" disabled value={config.hostname} />
	</FormInput>

	<FormInput label="name" flavour_text="what your instance is called">
		<input type="text" value={name} />
	</FormInput>

	<FormInput
		style="grid-column: 1 / span 2"
		label="subtitle"
		flavour_text="a short tagline or what have you"
	>
		<input type="text" name="short_desc" value={short_desc} />
	</FormInput>

	<FormInput
		style="grid-column: 1 / span 2"
		label="description"
		flavour_text="
		a place to put more words about your instance. 
		markdown works fine here
		"
	>
		<textarea rows="4" name="long_desc" value={long_desc} />
	</FormInput>

	<div style="grid-column: 1 / span 2;">
		<FoldingThing title="Rules" subtitle="lay down the law">
			<p>having some rules is a good idea.</p>
		</FoldingThing>
		<FoldingThing
			title="Terms of Service"
			subtitle="don't get sued"
		></FoldingThing>
		<FoldingThing
			title="Privacy Policy"
			subtitle="don't get sued (in europe)"
		></FoldingThing>
	</div>

	<PrefsFooter
		style="grid-column: 1 / span 2; margin-top: 2rem;"
		let:scrolled
	>
		<Button size="medium">↶ Revert</Button>

		<Button
			size={scrolled ? "medium" : "big"}
			style="margin-left: auto;"
			type="submit">Save ✓</Button
		>
	</PrefsFooter>
</form>

<!-- <form class="bz-form" method="post" style="margin-top: 2rem;">
	<section class="two-cols">
		<label>
			<span>Host Name</span>


@@ 74,4 135,21 @@
</form>

<style>
</style> -->

<style>
	.grid {
		display: grid;
		grid-template-columns: 1fr 1fr;
		gap: 1rem;
	}

	.text {
		grid-column: 1 / span 2;
	}

	p[role="alert"],
	p[role="status"] {
		margin-bottom: 1rem;
	}
</style>

M src/routes/its/[username]/+page.svelte => src/routes/its/[username]/+page.svelte +0 -1
@@ 3,7 3,6 @@
	import Filters from "./Filters.svelte";
	import HeaderImage from "./HeaderImage.svelte";
	import Metadata from "./Metadata.svelte";
	import Pinned from "./Pinned.svelte";
	import Placeholder from "./Placeholder.svelte";

	export let data;

M src/routes/its/[username]/Filters.svelte => src/routes/its/[username]/Filters.svelte +10 -31
@@ 1,6 1,6 @@
<script lang="ts">
    import Menu from "$lib/Bonus/Menu.svelte";
    import MenuProse from "$lib/Bonus/MenuProse.svelte";
    import SensibleMenu from "$lib/Bonus/SensibleMenu.svelte";
    import Checkbox from "$lib/Forms/Checkbox.svelte";
    import { Sliders } from "$lib/Icons";
    import Icon from "$lib/Icons/Icon.svelte";


@@ 15,8 15,6 @@

    export let filter: FilterType, own_page: boolean;

    let trigger: HTMLButtonElement, open: boolean, xy: [number, number];

    const filters = <{ id: FilterType; label: string }[]>(
        [
            { id: "everything", label: own_page ? "Public" : "All Posts" },


@@ 27,21 25,6 @@
            { id: "media", label: "Media" },
        ].filter((o) => o)
    );

    function onclick(e: unknown) {
        let event = e as MouseEvent & {
            currentTarget: EventTarget & HTMLButtonElement;
        };

        event.preventDefault();
        if (!open) {
            const rect = event.currentTarget.getBoundingClientRect();
            xy = [event.clientX || rect.x, event.clientY || rect.y];
            open = true;
        } else {
            open = false;
        }
    }
</script>

<div class="filters">


@@ 54,18 37,14 @@
            </li>
        {/each}
    </ul>
    <button bind:this={trigger} on:click={onclick}>
        <Icon source={Sliders} label="Settings" />
    </button>
    <Menu
        anchor="right"
        mode="freestyle"
        title="Filter Settings"
        bind:trigger
        bind:xy
        bind:open
    >
        <MenuProse style="height:max-content">
    <SensibleMenu mode="freestyle" anchor="right" title="filter posts">
        <svelte:fragment slot="button" let:open>
            <button on:click={open}>
                <Icon source={Sliders} label="Settings" />
            </button>
        </svelte:fragment>

        <MenuProse>
            <h1>Filters</h1>
            <div class="boxes">
                <Checkbox checked bigness="maybe">Show Reposts</Checkbox>


@@ 74,7 53,7 @@
                </Checkbox>
            </div>
        </MenuProse>
    </Menu>
    </SensibleMenu>
</div>

<style>

M src/routes/its/[username]/HeaderImage.svelte => src/routes/its/[username]/HeaderImage.svelte +1 -1
@@ 8,7 8,7 @@
    @keyframes fade-in {
        0% {
            opacity: 0;
            scale: 1.1;
            scale: 1.05;
        }
        100% {
            opacity: 1;

M src/routes/its/[username]/Metadata.svelte => src/routes/its/[username]/Metadata.svelte +7 -7
@@ 1,20 1,20 @@
<script lang="ts">
	import ProfileHeader from "./ProfileHeader.svelte";
	import type { PostData } from "$lib/server/Model/post";
	import type { UserPage } from "$lib/server/Model/userpage";
	import MetadataTable from "./MetadataTable.svelte";
	import Pinned from "./Pinned.svelte";
	import type { UserPageData } from "$lib/server/Model/userpage";
	import type { PostData } from "$lib/server/Model/post";
	import ProfileHeader from "./ProfileHeader.svelte";

	export let page: UserPageData;
	export let page: UserPage;
	export let own_page = false;
	export let logged_in = false;
	export let is_following: boolean;
	export let pinned: PostData;
	export let pinned: PostData | undefined = undefined;

	const table_values = new Map<
		string,
		string | { value: string; linkify: boolean }
	>();
	>(page.bonus_metadata);

	if (page.pronouns) table_values.set("Pronouns", page.pronouns);



@@ 25,7 25,7 @@
<ProfileHeader {page} {own_page} {logged_in} {is_following}></ProfileHeader>

<div>
	{#if page.homepage || page.pronouns}
	{#if table_values.size != 0}
		<section>
			<MetadataTable values={table_values} />
		</section>

M src/routes/its/[username]/MetadataTable.svelte => src/routes/its/[username]/MetadataTable.svelte +3 -1
@@ 1,4 1,6 @@
<script lang="ts">
    import sanitize from "sanitize-html";

    export let values: Map<
        string,
        string | undefined | { value?: string; linkify: boolean }


@@ 23,7 25,7 @@
                        {value.value}
                    {/if}
                {:else}
                    {value}
                    {@html sanitize(value ?? "")}
                {/if}
            </td>
        </tr>

M src/routes/its/[username]/ProfileHeader.svelte => src/routes/its/[username]/ProfileHeader.svelte +6 -3
@@ 5,15 5,18 @@
    import Button from "$lib/Buttons/Button.svelte";
    import { Pencil } from "$lib/Icons";
    import Icon from "$lib/Icons/Icon.svelte";
    import type { UserPageData } from "$lib/server/Model/userpage";
    import type { UserPage } from "$lib/server/Model/userpage";
    import MoreButton from "./MoreButton.svelte";

    export let page: UserPageData;
    export let page: UserPage;
    export let own_page = false;
    export let logged_in = false;
    export let is_following: boolean;

    const { bio, display_name, username, avatar_uri } = page;
    const { bio, display_name, avatar_uri } = page;
    const username = "hostname" in page
        ? `${page.username}@${page.hostname}`
        : page.username;
</script>

<header>

A src/routes/its/[username]/at/[server]/+page.server.ts => src/routes/its/[username]/at/[server]/+page.server.ts +13 -0
@@ 0,0 1,13 @@
import { Actor } from "$lib/ActivityPub/actor";
import UserPage from "$lib/server/Model/userpage";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ params, locals }) => {
    const actor = await Actor.fetch_remote(params.username, params.server, {
        as: locals.page?.username,
    });
    console.log(actor);
    const page = UserPage.from_actor(actor);

    return { page }
};
\ No newline at end of file

A src/routes/its/[username]/at/[server]/+page.svelte => src/routes/its/[username]/at/[server]/+page.svelte +12 -0
@@ 0,0 1,12 @@
<script>
    import Metadata from "../../Metadata.svelte";

    export let data;
</script>

<Metadata
    is_following={false}
    page={data.page}
    logged_in={true}
    own_page={false}
/>

M src/routes/its/[username]/follow/+page.svelte => src/routes/its/[username]/follow/+page.svelte +2 -1
@@ 10,7 10,8 @@

<style>
	big {
		display: block;
		display: flex;
		align-items: center;

		font-family: var(--font-display);
		font-size: 5rem;

M src/routes/testpage/+page.svelte => src/routes/testpage/+page.svelte +2 -2
@@ 15,7 15,7 @@
        Number9Alt,
    } from "$lib/Icons";
    import ContextMenuItem from "$lib/Bonus/MenuItem.svelte";
    import { show_heads_up } from "$lib/Pings";
    import { show_toast } from "$lib/Pings";
    import { goto } from "$app/navigation";
    import { setContext } from "svelte";



@@ 47,7 47,7 @@
<br />

<Button
    on:click={(e) => show_heads_up({
    on:click={(e) => show_toast({
        text: "Post Published!",
        noisy: false,
        buttons: [

M src/routes/user/[username]/+server.ts => src/routes/user/[username]/+server.ts +10 -6
@@ 1,16 1,20 @@
import { ap_accept, check_for_accept_header } from '$lib/ActivityPub';
import { Actor } from '$lib/ActivityPub/actor';
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { check_for_accept_header } from '$lib/ActivityPub';

export const GET: RequestHandler = async ({
	request,
	params: { username },
}) => {
	check_for_accept_header(request, username);
	
	const actor = await Actor.from_db(username);
	const accepted = check_for_accept_header(request, username);
	if (!accepted) {
		throw redirect(303, `/its/${username}`);
	}

	return new Response(actor.to_object(), {
		headers: { 'Content-Type': 'application/activity+json' },
	const actor = await Actor.fetch_local(username);

	return new Response(actor.to_ap(), {
		headers: { 'Content-Type': ap_accept },
	});
};