From 2195dc2a6d9db92184c4b65c03d81dfc3ad62bb5 Mon Sep 17 00:00:00 2001 From: leah Date: Mon, 27 May 2024 18:41:42 +0100 Subject: [PATCH] yeah --- bonus/federation.md | 80 ++++++++++++++++ bonus/innit/basingstoke.dinit | 6 ++ .../innit/basingstoke.service | 0 bonus/setup.md | 46 ++++++++++ cache_schema.sql | 11 --- config.stoke.example.toml | 5 +- blob_schema.sql => data/blob-schema.sql | 0 data/cache-schema.sql | 19 ++++ data/innit.sh | 5 + data/reset.sh | 4 + schema.sql => data/schema.sql | 19 ++-- src/lib/ActivityPub/actor.ts | 71 ++++++++++---- src/lib/ActivityPub/caching.ts | 0 src/lib/ActivityPub/index.ts | 10 +- src/lib/ActivityPub/model.ts | 9 +- src/lib/ActivityPub/signing.ts | 43 +++++++++ src/lib/ActivityPub/webfinger/fetch.ts | 57 ++++++++++++ src/lib/ActivityPub/webfinger/index.ts | 6 ++ src/lib/Bonus/ContextMenu.svelte | 5 +- src/lib/Bonus/Menu.svelte | 4 +- src/lib/Bonus/PrefsFooter.svelte | 2 +- src/lib/Bonus/SensibleMenu.svelte | 5 + src/lib/Bonus/WarningBox.svelte | 44 +++++++++ src/lib/Colours/colours.json | 4 +- src/lib/Colours/index.ts | 6 ++ src/lib/Forms/FoldingThing.svelte | 67 ++++++++++++++ src/lib/Forms/FormInput.svelte | 2 +- src/lib/Media/index.ts | 58 ++++++------ src/lib/Navigation/PrefsMenu.svelte | 76 ++++++++++++--- src/lib/Pings/HeadsUpses.svelte | 85 ----------------- .../Pings/{HeadsUp.svelte => Toast.svelte} | 6 +- src/lib/Pings/ToastList.svelte | 92 +++++++++++++++++++ src/lib/Pings/index.ts | 12 +-- src/lib/config.ts | 1 + src/lib/server/Auth/keys.ts | 38 ++++---- src/lib/server/Model/userpage.ts | 62 +++++++++++-- src/lib/server/db.ts | 13 +-- src/routes/(guarded)/prefs/+page.svelte | 2 +- .../prefs/appearance/ColourPicker.svelte | 29 +++--- .../(guarded)/prefs/cliques/+page.svelte | 22 +++++ .../(guarded)/prefs/profile/+page.server.ts | 4 +- src/routes/+layout.svelte | 6 +- src/routes/Footer.svelte | 3 +- src/routes/admin/instance/+page.svelte | 92 +++++++++++++++++-- src/routes/its/[username]/+page.svelte | 1 - src/routes/its/[username]/Filters.svelte | 41 ++------- src/routes/its/[username]/HeaderImage.svelte | 2 +- src/routes/its/[username]/Metadata.svelte | 14 +-- .../its/[username]/MetadataTable.svelte | 4 +- .../its/[username]/ProfileHeader.svelte | 9 +- .../[username]/at/[server]/+page.server.ts | 13 +++ .../its/[username]/at/[server]/+page.svelte | 12 +++ src/routes/its/[username]/follow/+page.svelte | 3 +- src/routes/testpage/+page.svelte | 4 +- src/routes/user/[username]/+server.ts | 16 ++-- 55 files changed, 946 insertions(+), 304 deletions(-) create mode 100644 bonus/federation.md create mode 100644 bonus/innit/basingstoke.dinit rename src/lib/Forms/FormItem.svelte => bonus/innit/basingstoke.service (100%) create mode 100644 bonus/setup.md delete mode 100644 cache_schema.sql rename blob_schema.sql => data/blob-schema.sql (100%) create mode 100644 data/cache-schema.sql create mode 100755 data/innit.sh create mode 100755 data/reset.sh rename schema.sql => data/schema.sql (89%) create mode 100644 src/lib/ActivityPub/caching.ts create mode 100644 src/lib/ActivityPub/signing.ts create mode 100644 src/lib/ActivityPub/webfinger/fetch.ts create mode 100644 src/lib/ActivityPub/webfinger/index.ts create mode 100644 src/lib/Bonus/WarningBox.svelte create mode 100644 src/lib/Forms/FoldingThing.svelte delete mode 100644 src/lib/Pings/HeadsUpses.svelte rename src/lib/Pings/{HeadsUp.svelte => Toast.svelte} (96%) create mode 100644 src/lib/Pings/ToastList.svelte create mode 100644 src/routes/(guarded)/prefs/cliques/+page.svelte create mode 100644 src/routes/its/[username]/at/[server]/+page.server.ts create mode 100644 src/routes/its/[username]/at/[server]/+page.svelte diff --git a/bonus/federation.md b/bonus/federation.md new file mode 100644 index 0000000..930c598 --- /dev/null +++ b/bonus/federation.md @@ -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: ` 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 diff --git a/bonus/innit/basingstoke.dinit b/bonus/innit/basingstoke.dinit new file mode 100644 index 0000000..d2f4b74 --- /dev/null +++ b/bonus/innit/basingstoke.dinit @@ -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 diff --git a/src/lib/Forms/FormItem.svelte b/bonus/innit/basingstoke.service similarity index 100% rename from src/lib/Forms/FormItem.svelte rename to bonus/innit/basingstoke.service diff --git a/bonus/setup.md b/bonus/setup.md new file mode 100644 index 0000000..068117a --- /dev/null +++ b/bonus/setup.md @@ -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 diff --git a/cache_schema.sql b/cache_schema.sql deleted file mode 100644 index b85f88d..0000000 --- a/cache_schema.sql +++ /dev/null @@ -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; diff --git a/config.stoke.example.toml b/config.stoke.example.toml index d1765f7..b9dcc4c 100644 --- a/config.stoke.example.toml +++ b/config.stoke.example.toml @@ -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 diff --git a/blob_schema.sql b/data/blob-schema.sql similarity index 100% rename from blob_schema.sql rename to data/blob-schema.sql diff --git a/data/cache-schema.sql b/data/cache-schema.sql new file mode 100644 index 0000000..221ef52 --- /dev/null +++ b/data/cache-schema.sql @@ -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; diff --git a/data/innit.sh b/data/innit.sh new file mode 100755 index 0000000..55ed022 --- /dev/null +++ b/data/innit.sh @@ -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 diff --git a/data/reset.sh b/data/reset.sh new file mode 100755 index 0000000..c3e38b8 --- /dev/null +++ b/data/reset.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +rm stoke*.db* +./innit.sh \ No newline at end of file diff --git a/schema.sql b/data/schema.sql similarity index 89% rename from schema.sql rename to data/schema.sql index cb16129..6993038 100644 --- a/schema.sql +++ b/data/schema.sql @@ -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 diff --git a/src/lib/ActivityPub/actor.ts b/src/lib/ActivityPub/actor.ts index 228187f..f066112 100644 --- a/src/lib/ActivityPub/actor.ts +++ b/src/lib/ActivityPub/actor.ts @@ -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) => `` -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[] = []; - 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, diff --git a/src/lib/ActivityPub/caching.ts b/src/lib/ActivityPub/caching.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/ActivityPub/index.ts b/src/lib/ActivityPub/index.ts index 4504ec5..5acb4cd 100644 --- a/src/lib/ActivityPub/index.ts +++ b/src/lib/ActivityPub/index.ts @@ -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', diff --git a/src/lib/ActivityPub/model.ts b/src/lib/ActivityPub/model.ts index 168f785..4903a87 100644 --- a/src/lib/ActivityPub/model.ts +++ b/src/lib/ActivityPub/model.ts @@ -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 diff --git a/src/lib/ActivityPub/signing.ts b/src/lib/ActivityPub/signing.ts new file mode 100644 index 0000000..051a0bb --- /dev/null +++ b/src/lib/ActivityPub/signing.ts @@ -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 +): ReturnType { + 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 diff --git a/src/lib/ActivityPub/webfinger/fetch.ts b/src/lib/ActivityPub/webfinger/fetch.ts new file mode 100644 index 0000000..02416bb --- /dev/null +++ b/src/lib/ActivityPub/webfinger/fetch.ts @@ -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 { + 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 diff --git a/src/lib/ActivityPub/webfinger/index.ts b/src/lib/ActivityPub/webfinger/index.ts new file mode 100644 index 0000000..89c1fdc --- /dev/null +++ b/src/lib/ActivityPub/webfinger/index.ts @@ -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 diff --git a/src/lib/Bonus/ContextMenu.svelte b/src/lib/Bonus/ContextMenu.svelte index 421520f..5b7b8dd 100644 --- a/src/lib/Bonus/ContextMenu.svelte +++ b/src/lib/Bonus/ContextMenu.svelte @@ -25,6 +25,7 @@ open = true; } } +
-
- + + diff --git a/src/lib/Colours/colours.json b/src/lib/Colours/colours.json index 061e4eb..9dfb784 100644 --- a/src/lib/Colours/colours.json +++ b/src/lib/Colours/colours.json @@ -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"] ] diff --git a/src/lib/Colours/index.ts b/src/lib/Colours/index.ts index 73de0a0..331dfb9 100644 --- a/src/lib/Colours/index.ts +++ b/src/lib/Colours/index.ts @@ -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]; diff --git a/src/lib/Forms/FoldingThing.svelte b/src/lib/Forms/FoldingThing.svelte new file mode 100644 index 0000000..44d217b --- /dev/null +++ b/src/lib/Forms/FoldingThing.svelte @@ -0,0 +1,67 @@ + + +
+ +
+

{title}

+ {#if subtitle} + {subtitle} + {/if} +
+ + +
+ +
+ + diff --git a/src/lib/Forms/FormInput.svelte b/src/lib/Forms/FormInput.svelte index dd19184..fcfedf1 100644 --- a/src/lib/Forms/FormInput.svelte +++ b/src/lib/Forms/FormInput.svelte @@ -3,7 +3,7 @@ flavour_text: string | undefined = undefined; -

@@ -26,7 +27,6 @@
  • details
  • appearance
  • accessibility
  • -
  • wellbeing
  • pings
  • safety
  • diff --git a/src/routes/(guarded)/prefs/appearance/ColourPicker.svelte b/src/routes/(guarded)/prefs/appearance/ColourPicker.svelte index aab5880..1b8d64f 100644 --- a/src/routes/(guarded)/prefs/appearance/ColourPicker.svelte +++ b/src/routes/(guarded)/prefs/appearance/ColourPicker.svelte @@ -1,17 +1,11 @@
      @@ -25,10 +19,13 @@ --set-foreground-tone: color-mix(in srgb, {colour_set[2]}, {colour_set[1]} 10%);" >
    - - - + + + + + +

    Filters

    Show Reposts @@ -74,7 +53,7 @@
    -
    +