Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

View File

@@ -0,0 +1,26 @@
export function splitUsername(handle: string, localDomain?: string) {
const cleaned = handle.replace(/^@/, "").trim();
if (!cleaned.includes("@")) {
return [cleaned, localDomain];
}
let [user, domain] = cleaned.split("@");
return [user, domain]
}
export function handleFromRecordWithIRI(record: any) {
if (!record.expand?.author) {
throw new Error("object has no author info")
}
if (!record.iri) {
return `@${record.expand.author.username}`
}
const url = new URL(record.iri ?? "")
return `@${record.expand.author.username}@${url.hostname}`
}

View File

@@ -1,4 +1,4 @@
import { error, type NumericRange, type RequestEvent } from "@sveltejs/kit";
import { error, json, type NumericRange, type RequestEvent } from "@sveltejs/kit";
import { ClientResponseError, type ListResult } from "pocketbase";
import { ZodError, type ZodSchema } from "zod";
import { RecordListOptionsSchema, RecordIdSchema, RecordOptionsSchema } from "$lib/models/api/base_schema";
@@ -19,6 +19,8 @@ export class APIError extends Error {
export enum Collection {
users = "users",
activitypub_activities = "activitypub_activities",
activitypub_actors = "activitypub_actors",
categories = "categories",
comments = "comments",
follows = "follows",
@@ -28,15 +30,15 @@ export enum Collection {
notifications = "notifications",
settings = "settings",
summit_logs = "summit_logs",
trail_like = "trail_like",
trail_share = "trail_share",
trails = "trails",
tags = "tags",
waypoints = "waypoints",
activities = "activities",
follow_counts = "follow_counts",
timeline = "timeline",
trails_bounding_box = "trails_bounding_box",
trails_filter = "trails_filter",
users_anonymous = "users_anonymous"
users_anonymous = "users_anonymous",
}
export async function list<T>(event: RequestEvent, collection: Collection) {
@@ -81,7 +83,7 @@ export async function create<T>(event: RequestEvent, schema: ZodSchema, collecti
const data = await event.request.json();
const safeData = schema.parse(data);
const r = await event.locals.pb.collection(Collection[collection]).create<T>(safeData, {...safeSearchParams, requestKey: null})
const r = await event.locals.pb.collection(Collection[collection]).create<T>(safeData, { ...safeSearchParams, requestKey: null })
return r
}
@@ -101,6 +103,33 @@ export async function update<T>(event: RequestEvent, schema: ZodSchema, collecti
return r
}
export async function uploadCreate<T>(event: RequestEvent, collection: Collection) {
const searchParams = Object.fromEntries(event.url.searchParams);
const safeSearchParams = RecordOptionsSchema.parse(searchParams);
const data = await event.request.formData();
const r = await event.locals.pb.collection(Collection[collection]).create<T>(data, safeSearchParams)
return r
}
export async function uploadUpdate<T>(event: RequestEvent, collection: Collection) {
const searchParams = Object.fromEntries(event.url.searchParams);
const safeSearchParams = RecordOptionsSchema.parse(searchParams);
const data = await event.request.formData();
if (!data.has('id')) {
throw new Error("data has no id")
}
const r = await event.locals.pb.collection(Collection[collection]).update<T>(data.get('id')!.toString(), data, safeSearchParams)
return r
}
export async function upload<T>(event: RequestEvent, collection: Collection) {
const params = event.params
const safeParams = RecordIdSchema.parse(params);
@@ -123,12 +152,12 @@ export async function remove(event: RequestEvent, collection: Collection) {
export function handleError(e: any) {
if (e instanceof ZodError) {
return error(400, { message: "invalid_params", detail: e.issues } as any)
return json({ message: "invalid_params", detail: e.issues }, { status: 400 })
} else if (e instanceof ClientResponseError && e.status > 0) {
return error(e.status as NumericRange<400, 599>, {...e.response, message: e.message, detail: e.originalError.data } as any)
return json({ ...e.response, message: e.message, detail: e.originalError.data }, { status: e.status })
} else if (e instanceof SyntaxError) {
return error(400, "invalid_json")
return json({ message: "invalid_json" }, { status: 400 })
} else {
return error(500, e.toString())
return json({ message: e }, { status: 500 })
}
}

View File

@@ -1,3 +1,5 @@
import { env } from "$env/dynamic/public";
const privateRoutes = [
"/settings",
"/trail/edit/new",
@@ -5,7 +7,25 @@ const privateRoutes = [
"/lists/edit/new",
]
const publicRoutes = [
"/",
"/login",
"/api/v1/auth",
"/api/v1/user",
"/api/v1/category",
"/register",
"/auth"
]
export function isRouteProtected(path: string) {
if (env.PUBLIC_PRIVATE_INSTANCE == "true") {
return !publicRoutes.some(allowedPath =>
path === allowedPath || path.startsWith(allowedPath + '/')
);
}
return privateRoutes.some(allowedPath =>
path === allowedPath || path.startsWith(allowedPath + '/')
);

View File

@@ -1,10 +1,24 @@
export function getFileURL(record: { [key: string]: any; }, filename?: string) {
export function getFileURL(record: { [key: string]: any; }, filename?: string, thumb?: string) {
if (!filename) {
return "";
}
if (isURL(filename)) {
return filename;
}
return `/api/v1/files/${record.collectionId}/${record.id}/${filename}`
return `/api/v1/files/${record.collectionId}/${record.id}/${filename}${thumb ? '?thumb='+thumb : ''}`
}
export function isURL(value: string) {
let url
try {
url = new URL(value);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
export function readAsDataURLAsync(file: File) {
@@ -19,7 +33,7 @@ export function readAsDataURLAsync(file: File) {
}
export function isVideoURL(url: string) {
if(url.startsWith("data")) {
if (url.startsWith("data")) {
return url.startsWith("data:video")
}
return url.includes("mp4") || url.includes("ogg") || url.includes("webm")
@@ -34,4 +48,27 @@ export function saveAs(data: Blob, fileName: string) {
a.download = fileName;
a.click();
window.URL.revokeObjectURL(url);
};
};
function buildFormData(formData: FormData, data: any, parentKey?: string, exclude?: string[]) {
if (data && typeof data === 'object' && !(data instanceof Date) && !(data instanceof File) && !(data instanceof Blob)) {
Object.keys(data).forEach(key => {
if (exclude?.includes(key)) {
return;
}
buildFormData(formData, data[key], parentKey ? `${parentKey}` : key);
});
} else {
const value = data == null ? '' : data;
formData.append(parentKey!, value);
}
}
export function objectToFormData(data: Object, exclude?: string[]) {
const formData = new FormData();
buildFormData(formData, data, undefined, exclude);
return formData;
}

View File

@@ -1,3 +1,4 @@
import { browser } from "$app/environment";
import { page } from "$app/state";
import { get } from "svelte/store";
@@ -89,4 +90,62 @@ export function formatTimeSince(date: Date) {
return { unit: "minutes", value: Math.floor(interval) };
}
return { unit: "seconds", value: seconds };
}
export function formatHTMLAsText(html?: string) {
if(!html || !browser) {
return ""
}
// Create a temporary DOM element
const tempDiv = document.createElement("div");
tempDiv.innerHTML = html;
// Replace <br> with newlines to preserve line breaks
tempDiv.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
// Replace block-level elements with newlines before and after
const blockTags = new Set([
"DIV",
"P",
"LI",
"SECTION",
"ARTICLE",
"HEADER",
"FOOTER",
"ASIDE",
"MAIN",
"NAV",
"FIGURE",
"TABLE",
"TR",
"TD",
"TH",
"UL",
"OL",
"PRE",
]);
tempDiv.querySelectorAll("*").forEach((el) => {
if (blockTags.has(el.tagName)) {
el.insertAdjacentText("beforebegin", "\n");
el.insertAdjacentText("afterend", "\n");
}
});
// Extract the text content
let text = tempDiv.textContent;
if (!text) {
return "";
}
// Replace multiple spaces and newlines with a single one if needed
// Optional: collapse excessive blank lines to max two
text = text
.replace(/[ \t]+\n/g, "\n") // trailing spaces
.replace(/\n[ \t]+/g, "\n") // leading spaces
.replace(/\n{3,}/g, "\n\n") // collapse 3+ newlines
.replace(/[ \t]{2,}/g, " "); // collapse multiple spaces to two
// Trim the result
return text.trim();
}

View File

@@ -10,6 +10,7 @@ import { get } from "svelte/store";
import { getFileURL } from "./file_util";
import { formatDistance, formatElevation, formatTimeHHMM } from "./format_util";
import { icons } from "./icon_util";
import { handleFromRecordWithIRI } from "./activitypub_util";
export class FontawesomeMarker extends M.Marker {
constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string, id?: string }, markerOptions?: M.MarkerOptions) {
@@ -167,14 +168,14 @@ export function createEditTrailMapPopup(lnglat: M.LngLat, onCreateWaypointClick:
export function createPopupFromTrail(trail: Trail) {
const thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail ?? 0])
? getFileURL(trail, trail.photos.at(trail.thumbnail ?? 0) ?? trail.photos[0])
: get(theme) === "light"
? emptyStateTrailLight
: emptyStateTrailDark;
const popup = new M.Popup({ maxWidth: "420px" });
// Create a container element for the popup content
const linkElement = document.createElement("a");
linkElement.href = `/map/trail/${trail.id}`; // Set href safely
linkElement.href = `/map/trail/${handleFromRecordWithIRI(trail)}/${trail.id}`; // Set href safely
linkElement.setAttribute("data-sveltekit-preload-data", "off");
// Create a list item element