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:
26
web/src/lib/util/activitypub_util.ts
Normal file
26
web/src/lib/util/activitypub_util.ts
Normal 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}`
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 + '/')
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user