Federation Refactoring & Architecture Improvements (#930)

* initial commit

* add lists

* update permissions

* fix waypoint create

* needs_full_sync for activitypub trails

* fixes build issues

* fix list get

* further CSRF protection

* update API docs

* adds rate limiter

* fix tiptap mentions

* a bit more cleanup of main.go

* Fix migration order

* fixes reviewed notes

* require context for activitpub server calls

* improve hashing for identifier

* fix copy paste error

* fix sync trail/list issues

* fix summit log/comment duplicates

* adaptions after trail merge

* fix dockerignore

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
Flomp
2026-05-09 17:30:34 +02:00
committed by GitHub
parent 992ebfc0c4
commit d2ac49470a
73 changed files with 3773 additions and 2153 deletions

2
web/package-lock.json generated
View File

@@ -54,7 +54,7 @@
"pocketbase": "^0.26.8",
"qrcode": "^1.4.4",
"svelte-i18n": "^4.0.0",
"tailwindcss": "^4.2.3",
"tailwindcss": "^4.2.4",
"three": "^0.183.1",
"vitest": "^4.1.4",
"zod": "^3.24.1"

View File

@@ -43,7 +43,7 @@
error = "",
placeholder = "",
extraClasses = "",
searchListPosition = "absolute"
searchListPosition = "absolute",
}: Props = $props();
const fontSizes: SelectItem[] = [
@@ -87,7 +87,9 @@
return [
"a",
mergeAttributes(
{ href: `/profile/@${options.HTMLAttributes["data-label"]}` },
{
href: `/profile/@${options.HTMLAttributes["data-label"]}`,
},
options.HTMLAttributes,
),
options.renderText?.({
@@ -141,8 +143,9 @@
if (!box) {
return;
}
searchListElement.style.position = searchListPosition;
searchListElement.style.position =
searchListPosition;
searchListElement.style.top = `${box.bottom + window.scrollY + 4}px`;
searchListElement.style.left = `${box.left + window.scrollX}px`;
searchListElement.style.zIndex = "1001";
@@ -179,12 +182,12 @@
onKeyDown(props) {
if (props.event.key === "Escape") {
this.onExit?.({} as unknown as any)
this.onExit?.({} as unknown as any);
return true;
}
return false
return false;
},
onExit() {
unmount(component);
@@ -315,7 +318,7 @@
})
.run();
modal.closeModal();
modal.closeModal();
} catch (e) {
if (
e instanceof ZodError &&

View File

@@ -547,16 +547,19 @@
clusterPopup.on("close", () => {
unHighlightCluster(false);
});
map.on("mousemove", unHighlightClusterDistanceNotifier)
map.on("mousemove", unHighlightClusterDistanceNotifier);
}
function unHighlightClusterDistanceNotifier(e: M.MapMouseEvent) {
if (!clusterPopup || !map) {
return
return;
}
if (map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) > 60) {
if (
map.project(clusterPopup.getLngLat()).dist(map.project(e.lngLat)) >
60
) {
clusterPopup.remove();
map.off("mousemove", unHighlightClusterDistanceNotifier)
map.off("mousemove", unHighlightClusterDistanceNotifier);
}
}

View File

@@ -129,9 +129,8 @@
async function fetchComments() {
commentsLoading = true;
const trailId = trail.iri ? trail.iri : trail.id!;
try {
await comments_index(trailId, handle);
await comments_index(trail.id!);
} catch (e) {
show_toast({
type: "error",

View File

@@ -7,8 +7,8 @@ export interface Actor {
domain?: string;
summary?: string;
published?: string;
followerCount?: number,
followingCount?: number,
follower_count?: number,
following_count?: number,
iri: string;
inbox: string;
outbox?: string;

View File

@@ -22,6 +22,7 @@ export class List {
}
created?: string;
updated?: string;
author: string;
constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) {

View File

@@ -1,25 +1,17 @@
import { Comment } from "$lib/models/comment";
import type { Trail } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { currentUser } from "./user_store";
import { isURL } from "$lib/util/file_util";
export const comments: Writable<Comment[]> = writable([])
export async function comments_index(trailId: string, handle?: string) {
export async function comments_index(trailId: string) {
let filter: string;
if (isURL(trailId)) {
filter = `trail="${trailId}"||trail.iri="${trailId}"||trail="${trailId.substring(trailId.length - 15)}"`
} else {
filter = `trail="${trailId}"`
}
let r = await fetch(`/api/v1/comment?` + new URLSearchParams({
filter,
let r = await fetch(`/api/v1/trail/${trailId}/comment?` + new URLSearchParams({
expand: "author",
sort: "-created",
...(handle ? { handle } : {})
}), {
method: 'GET',
})

View File

@@ -5,11 +5,9 @@ import { type ListResult } from "pocketbase";
let follows: Actor[] = [];
export async function follows_index(data: { username: string, type: "followers" | "following" }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
export async function follows_index(page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/follow?` + new URLSearchParams({
handle: data.username,
type: data.type,
page: page.toString(),
perPage: perPage.toString(),
}), {
@@ -25,7 +23,7 @@ export async function follows_index(data: { username: string, type: "followers"
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
follows = result;
follows = result;
return { ...fetchedFollows, items: result };
}

View File

@@ -9,9 +9,10 @@ import { searchResultToLists } from "./list_store";
import type { ListSearchResult } from "./search_store";
import { buildFilterText } from "./summit_log_store";
import { searchResultToTrailList } from "./trail_store";
import type { Actor } from "$lib/models/activitypub/actor";
let feed: FeedItem[] = []
let follows: Actor[] = [];
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/profile/' + handle, {
@@ -132,4 +133,26 @@ export async function profile_stats_index(handle: string, filter: SummitLogFilte
return result;
}
export async function profile_follows_index(handle: string, type: "followers" | "following", page: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/profile/${handle}/follows?` + new URLSearchParams({
type,
page: page.toString(),
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedFollows: ListResult<Actor> = await r.json();
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
follows = result;
return { ...fetchedFollows, items: result };
}

View File

@@ -12,7 +12,7 @@ export async function waypoints_create(waypoint: Waypoint, f: (url: RequestInfo
throw Error("Unauthenticated")
}
waypoint.author = user.id
waypoint.author = user.actor
let r = await f('/api/v1/waypoint', {
method: 'PUT',

View File

@@ -2,7 +2,6 @@ import { CommentCreateSchema } from '$lib/models/api/comment_schema';
import type { Comment } from '$lib/models/comment';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { type ListResult } from "pocketbase";
/**
* @swagger
@@ -32,11 +31,6 @@ import { type ListResult } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* description: Federated query parameter
* responses:
* 200:
* description: List of comments
@@ -51,70 +45,8 @@ import { type ListResult } from "pocketbase";
*/
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("handle")) {
const comments = await list<Comment>(event, Collection.comments);
return json(comments)
} else {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const localComments = await list<Comment>(event, Collection.comments);
if (actor.isLocal) {
return json(localComments)
}
const deduplicationMap: Record<string, Comment> = {}
localComments.items.forEach(c => {
if (c.iri) {
const id = c.iri.substring(c.iri.length - 15)
deduplicationMap[id] = c
} else if (c.id) {
deduplicationMap[c.id] = c
}
})
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/comment`
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
}
const remoteComments: ListResult<Comment> = await response.json()
remoteComments.items = remoteComments.items.filter(c => {
const iriId = c.iri?.substring(c.iri.length - 15) ?? ""
if (deduplicationMap[c.id!] != undefined) {
deduplicationMap[c.id!] = { ...c, author: deduplicationMap[c.id!].author }
return false
} else if (deduplicationMap[iriId] != undefined) {
deduplicationMap[iriId] = { ...c, author: deduplicationMap[iriId].author }
return false
}
return true
})
remoteComments.items.forEach(c => {
if (!c.iri?.length) {
c.iri = `${url}/${c.id}`
}
})
const allCommentItems = <ListResult<Comment>>{
items: localComments.items.concat(remoteComments.items),
page: localComments.page,
perPage: localComments.perPage,
totalItems: localComments.items.length + remoteComments.items.length,
totalPages: Math.ceil((localComments.items.length + remoteComments.items.length) / localComments.perPage)
}
allCommentItems.items = allCommentItems.items.sort((a, b) => {
return new Date(b.created ?? 0).getTime() - new Date(a.created ?? 0).getTime()
})
return json(allCommentItems)
}
const comments = await list<Comment>(event, Collection.comments);
return json(comments)
} catch (e) {
return handleError(e)
}

View File

@@ -1,18 +1,14 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
import type { Follow } from '$lib/models/follow';
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
import { APIError, Collection, handleError, list } from '$lib/util/api_util';
import { Collection, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import type { APOrderedCollectionPage } from 'activitypub-types';
import { ClientResponseError, type ListResult } from "pocketbase";
/**
* @swagger
* /api/v1/follow:
* get:
* summary: List follows
* description: Retrieves follows or ActivityPub follower/following collections. Supports federated queries via handle parameter
* tags:
* - Follows
* parameters:
@@ -36,18 +32,9 @@ import { ClientResponseError, type ListResult } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* - in: query
* name: type
* schema:
* type: string
* enum: [followers, following]
* responses:
* 200:
* description: List of follows or ActivityPub collection
* description: List of follows
* content:
* application/json:
* schema:
@@ -59,60 +46,8 @@ import { ClientResponseError, type ListResult } from "pocketbase";
*/
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("handle")) {
const follows = await list<Follow>(event, Collection.follows);
return json(follows)
} else {
const handle = event.url.searchParams.get("handle");
const type = event.url.searchParams.get("type");
if (!handle || (type !== "followers" && type !== "following")) {
throw new APIError(400, "invalid params")
}
const { actor } = await getActorResponseForHandle(event, handle);
const page = event.url.searchParams.get("page") ?? "1"
let followers: APOrderedCollectionPage;
// fetch followers locally to not run into auth issues with private profiles
if (actor.id === event.locals.user?.actor) {
const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }))
if (!r.ok) {
const errorResponse = await r.json()
throw new ClientResponseError({ status: r.status, response: errorResponse });
}
followers = await r.json()
} else {
followers = await event.locals.pb.send(`/activitypub/actor/${actor.id}/${type}?page=${page}`, { method: "GET", fetch: event.fetch, });
}
const followerActors: Actor[] = []
for (const f of followers.orderedItems ?? []) {
try {
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${f}`, { method: "GET", fetch: event.fetch, });
followerActors.push(actor)
} catch (e) {
continue
}
}
const result: ListResult<Actor> = {
items: followerActors,
page: parseInt(page),
perPage: 10,
totalItems: actor.followerCount ?? 0,
totalPages: Math.ceil((actor.followerCount ?? 0) / 10)
}
return json(result)
}
const follows = await list<Follow>(event, Collection.follows);
return json(follows)
} catch (e) {
return handleError(e)
}

View File

@@ -1,16 +1,14 @@
import { ListUpdateSchema } from "$lib/models/api/list_schema";
import type { List } from "$lib/models/list";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { objectToFormData } from "$lib/util/file_util";
import { Collection, handleError, remove, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
/**
* @swagger
* /api/v1/list/{id}:
* get:
* summary: Get list
* description: Retrieves a list by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
* description: Retrieves a list by ID
* tags:
* - Lists
* parameters:
@@ -23,13 +21,9 @@ import { ClientResponseError } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* responses:
* 200:
* description: List with optional federated data
* description: List
* 404:
* description: Not Found
* 500:
@@ -80,77 +74,17 @@ import { ClientResponseError } from "pocketbase";
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
const { url, params } = event;
try {
if (!event.url.searchParams.has("handle")) {
const l = await show<List>(event, Collection.lists)
return json(l)
} else {
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/list/${event.params.id}`
let dbList: List | undefined;
try {
dbList = await event.locals.pb.collection("lists").getFirstListItem(`iri='${url}'||id='${event.params.id}'`, {
...Object.fromEntries(event.url.searchParams)
})
} catch (e) {
if (!(e instanceof ClientResponseError) || e.status != 404) {
throw e
}
}
if (actor.isLocal) {
return json(dbList)
} else {
const response = await event.fetch((dbList?.iri ?? url) + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
const cachedList = await event.locals.pb.collection("lists").getOne(`${event.params.id}`)
return json(cachedList)
}
const l: List = await response.json()
l.avatar = l.avatar ? `${origin}/api/v1/files/lists/${l.id}/${l.avatar}` : undefined
l.author = actor.id!
l.expand!.author = actor
l.iri = dbList?.iri ?? url;
l.expand?.trails?.forEach(t => {
t.gpx = t.gpx ? `${origin}/api/v1/files/trails/${t.id}/${t.gpx}` : undefined
t.photos = t.photos.map(p =>
`${origin}/api/v1/files/trails/${t.id}/${p}`
)
t.iri = t.iri || `${origin}/api/v1/trails/${t.id}`;
})
const formData = objectToFormData({ ...l, id: dbList?.id, expand: undefined, trails: [] })
if (l.avatar) {
const avatarURL = l.avatar
let response = await event.fetch(avatarURL, { method: "GET" })
const avatar = await response.blob()
formData.append("avatar", avatar)
}
if (dbList !== undefined) {
dbList = await event.locals.pb.collection("lists").update(dbList.id!, formData)
} else {
dbList = await event.locals.pb.collection("lists").create(formData)
}
l.id = dbList!.id
return json(l)
}
}
let list: List = await event.locals.pb.send(`/remote/list/${params.id}?` + url.searchParams, {
method: "GET",
fetch: event.fetch,
})
return json(list)
} catch (e: any) {
return handleError(e)
return handleError(e);
}
}

View File

@@ -46,8 +46,8 @@ export async function GET(event: RequestEvent) {
createdAt: actor.published ?? "",
bio: actor.summary ?? "",
uri: actor.iri,
followers: actor.followerCount ?? 0,
following: actor.followingCount ?? 0,
followers: actor.follower_count ?? 0,
following: actor.following_count ?? 0,
icon: actor.icon ?? "",
error: actorError ?? undefined
}

View File

@@ -0,0 +1,56 @@
import type { Comment } from '$lib/models/comment';
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
/**
* @swagger
* /api/v1/profile/{handle}/follows:
* get:
* summary: Get profile follows
* tags:
* - Profiles
* parameters:
* - in: path
* name: handle
* required: true
* schema:
* type: string
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: perPage
* schema:
* type: integer
* - in: query
* name: sort
* schema:
* type: string
* - in: query
* name: filter
* schema:
* type: string
* responses:
* 200:
* description: List of follows for the profile
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListResult'
* 400:
* description: Bad Request
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
let comments: Comment = await event.locals.pb.send(`/remote/profile/${event.params.handle}/follows?` + event.url.searchParams, {
method: "GET",
fetch: event.fetch,
})
return json(comments)
} catch (e) {
return handleError(e)
}
}

View File

@@ -1,4 +1,5 @@
import type { Actor } from '$lib/models/activitypub/actor';
import { getActorResponseForHandle } from '$lib/util/activitypub_server_util';
import { splitUsername } from '$lib/util/activitypub_util';
import { handleError } from '$lib/util/api_util';
import { error, json, type RequestEvent } from '@sveltejs/kit';
@@ -53,10 +54,10 @@ export async function GET(event: RequestEvent) {
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
}
const response = await event.locals.pb.collection("activitypub_actors").getList(1, 3, { filter: filter })
const response = await event.locals.pb.collection("activitypub_actors").getList<Actor>(1, 3, { filter: filter })
try {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${q}&follows=false`, { method: "GET", fetch: event.fetch, });
const { actor } = await getActorResponseForHandle(event, q!);
if (!response.items.find(i => i.iri == actor.iri)) {
response.items.push(actor)

View File

@@ -2,14 +2,12 @@ import { SummitLogCreateSchema } from '$lib/models/api/summit_log_schema';
import type { SummitLog } from '$lib/models/summit_log';
import { Collection, create, handleError, list } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
import { type ListResult } from "pocketbase";
/**
* @swagger
* /api/v1/summit-log:
* get:
* summary: List summit logs
* description: Retrieves a paginated list of summit logs with deduplication of federated data
* tags:
* - Summit Logs
* parameters:
@@ -33,13 +31,9 @@ import { type ListResult } from "pocketbase";
* name: expand
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* responses:
* 200:
* description: ListResult<SummitLog> with local/remote items deduplicated
* description: List of summit logs
* 400:
* description: Bad Request
* 500:
@@ -47,84 +41,10 @@ import { type ListResult } from "pocketbase";
*/
export async function GET(event: RequestEvent) {
try {
if (!event.url.searchParams.has("handle")) {
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
removeTimeFromDates(summitLogs.items)
return json(summitLogs)
} else {
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
const localSummitLogs = await list<SummitLog>(event, Collection.summit_logs);
if (actor.isLocal) {
removeTimeFromDates(localSummitLogs.items)
const summitLogs = await list<SummitLog>(event, Collection.summit_logs);
removeTimeFromDates(summitLogs.items)
return json(summitLogs)
return json(localSummitLogs)
}
const deduplicationMap: Record<string, SummitLog> = {}
localSummitLogs.items.forEach(l => {
if (l.iri) {
const id = l.iri.substring(l.iri.length - 15)
deduplicationMap[id] = l
} else if (l.id) {
deduplicationMap[l.id] = l
}
l.date = l.date.substring(0, 10);
})
const origin = new URL(actor.iri).origin
const url = `${origin}/api/v1/summit-log`
const response = await event.fetch(url + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
}
const remoteSummitLogs: ListResult<SummitLog> = await response.json()
remoteSummitLogs.items = remoteSummitLogs.items.filter(l => {
const iriId = l.iri?.substring(l.iri.length - 15) ?? ""
if (deduplicationMap[l.id!] != undefined) {
deduplicationMap[l.id!] = {...l, author: deduplicationMap[l.id!].author}
return false
} else if (deduplicationMap[iriId] != undefined) {
deduplicationMap[iriId] = {...l, author: deduplicationMap[iriId].author}
return false
}
return true
})
remoteSummitLogs.items.forEach(l => {
if (l.gpx) {
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
}
l.photos = l.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
)
if (l.expand?.author) {
l.expand.author.isLocal = false
}
})
const allSummitLogItems = <ListResult<SummitLog>>{
items: localSummitLogs.items.concat(remoteSummitLogs.items),
page: localSummitLogs.page,
perPage: localSummitLogs.perPage,
totalItems: localSummitLogs.items.length + remoteSummitLogs.items.length,
totalPages: Math.ceil((localSummitLogs.items.length + remoteSummitLogs.items.length) / localSummitLogs.perPage)
}
allSummitLogItems.items = allSummitLogItems.items.sort((a, b) => {
return new Date(a.created ?? 0).getTime() - new Date(b.created ?? 0).getTime()
})
return json(allSummitLogItems)
}
} catch (e) {
return handleError(e)
}

View File

@@ -1,18 +1,15 @@
import { RecordOptionsSchema } from '$lib/models/api/base_schema';
import { TrailUpdateSchema } from '$lib/models/api/trail_schema';
import type { Trail } from "$lib/models/trail";
import { APIError, Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { objectToFormData } from "$lib/util/file_util";
import { Collection, handleError, remove, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
import type PocketBase from "pocketbase";
import { ClientResponseError } from "pocketbase";
/**
* @swagger
* /api/v1/trail/{id}:
* get:
* summary: Get trail
* description: Retrieves a trail by ID. Supports federated queries via handle parameter, fetching from remote instances and remapping file URLs
* description: Retrieves a trail by ID
* tags:
* - Trails
* parameters:
@@ -26,149 +23,31 @@ import { ClientResponseError } from "pocketbase";
* schema:
* type: string
* - in: query
* name: handle
* schema:
* type: string
* - in: query
* name: share
* schema:
* type: string
* responses:
* 200:
* description: Trail with optional federated data
* description: Trail
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
const { url, params } = event;
try {
// try to get the trail simply via the
let t: Trail;
if (!event.url.searchParams.has("handle")) {
t = await show<Trail>(event, Collection.trails)
} else {
let { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${event.url.searchParams.get("handle")}`, { method: "GET", fetch: event.fetch, });
event.url.searchParams.delete("handle")
let trail: Trail = await event.locals.pb.send(`/remote/trail/${params.id}?` + url.searchParams, {
method: "GET",
fetch: event.fetch,
})
const safeSearchParams = RecordOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
if (event.url.searchParams.has("share")) {
safeSearchParams.query = { share: event.url.searchParams.get("share")! }
}
let origin = new URL(actor.iri).origin
let iri = `${origin}/api/v1/trail/${event.params.id}`
try {
t = await event.locals.pb.collection("trails").getFirstListItem(`iri='${iri}'||id='${event.params.id}'`, {
...safeSearchParams
})
} catch (e) {
if (!(e instanceof ClientResponseError) || e.status != 404) {
throw e
}
t = {
iri: iri,
author: actor.id,
like_count: 0
} as Trail
}
}
if (t.iri) {
const origin = new URL(t.iri).origin
const actor = await event.locals.pb.collection("activitypub_actors").getOne(t.author)
const localTrailId = t.id;
const localTrailIRI = t.iri;
const localLikeCount = t.like_count;
const localLikes = t.expand?.trail_like_via_trail;
const response = await event.fetch((t.iri) + '?' + event.url.searchParams, { method: 'GET' })
if (!response.ok) {
const errorResponse = await response.json()
console.error(errorResponse)
if (t.id) {
return json(t)
} else {
throw new ClientResponseError({ status: response.status, response: errorResponse })
}
}
t = await response.json()
// this came directly from the database of the remote instance
// we need to adjust some urls to get photos, gpx etc.
if (!t.iri) {
if (t.gpx) {
t.gpx = `${origin}/api/v1/files/trails/${t.id}/${t.gpx}`
}
t.photos = t.photos.map(p =>
`${origin}/api/v1/files/trails/${t.id}/${p}`
)
t.expand?.summit_logs_via_trail?.forEach(l => {
if (l.gpx) {
l.gpx = `${origin}/api/v1/files/summit_logs/${l.id}/${l.gpx}`
}
l.photos = l.photos.map(p =>
`${origin}/api/v1/files/summit_logs/${l.id}/${p}`
)
if (l.expand?.author) {
l.expand.author.isLocal = false
}
})
t.expand?.waypoints_via_trail?.forEach(w => {
w.photos = w.photos.map(p =>
`${origin}/api/v1/files/waypoints/${w.id}/${p}`
)
})
t.author = actor.id!
t.expand!.author = actor as any
t.id = localTrailId
t.iri = localTrailIRI
t.like_count = localLikeCount
t.expand!.trail_like_via_trail = localLikes;
}
let categoryId: string | undefined;
try {
const category = await event.locals.pb.collection("categories").getFirstListItem(`name='${t.expand?.category?.name}'`)
categoryId = category.id;
t.expand!.category = category as any
} catch (e) { }
const formData = objectToFormData({ ...t, id: t.id, gpx: undefined, expand: undefined, photos: [], waypoints: [], tags: [], category: categoryId })
if (t.photos.length) {
const photoURL = t.photos[t.thumbnail ?? 0]
let response = await event.fetch(photoURL, { method: "GET" })
const photo = await response.blob()
formData.append("photos", photo)
}
if (t.gpx) {
const gpxURL = t.gpx
const response = await event.fetch(gpxURL, { method: "GET" })
const gpx = await response.blob()
formData.append("gpx", gpx)
}
if (t.id) {
await event.locals.pb.collection("trails").update(t.id, formData)
} else {
const createdTrail = await event.locals.pb.collection("trails").create(formData)
t.id = createdTrail.id;
}
}
// remove time from dates
await enrichRecord(event.locals.pb, t);
// sort waypoints by distance
t.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(t)
await enrichRecord(event.locals.pb, trail);
trail.expand?.waypoints_via_trail?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
return json(trail)
} catch (e: any) {
return handleError(e)
return handleError(e);
}
}

View File

@@ -0,0 +1,56 @@
import type { Comment } from '$lib/models/comment';
import { handleError } from '$lib/util/api_util';
import { json, type RequestEvent } from '@sveltejs/kit';
/**
* @swagger
* /api/v1/trail/{id}/comment:
* get:
* summary: Get trail comments
* tags:
* - Trails
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: perPage
* schema:
* type: integer
* - in: query
* name: sort
* schema:
* type: string
* - in: query
* name: filter
* schema:
* type: string
* responses:
* 200:
* description: List of comments for the trail
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListResult'
* 400:
* description: Bad Request
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
let comments: Comment = await event.locals.pb.send(`/remote/trail/${event.params.id}/comments?` + event.url.searchParams, {
method: "GET",
fetch: event.fetch,
})
return json(comments)
} catch (e) {
return handleError(e)
}
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from "$app/state";
import { follows_index } from "$lib/stores/follow_store.js";
import { profile_follows_index } from "$lib/stores/profile_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { APIError } from "$lib/util/api_util.js";
import { untrack } from "svelte";
@@ -37,13 +37,10 @@
async function loadNextPage() {
pagination.page += 1;
try {
follows = await follows_index(
{
type: page.params.type as "followers" | "following",
username: page.params.handle!,
},
follows = await profile_follows_index(
page.params.handle!,
page.params.type as "followers" | "following",
pagination.page,
10,
fetch,
);
} catch (e) {
@@ -51,8 +48,8 @@
show_toast({
icon: "close",
text: `${e.status}: ${e.message}`,
type: "error"
})
type: "error",
});
}
}
}

View File

@@ -1,4 +1,5 @@
import { follows_index } from "$lib/stores/follow_store";
import { profile_follows_index } from "$lib/stores/profile_store";
import { APIError } from "$lib/util/api_util";
import { error, type Load } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
@@ -9,8 +10,12 @@ export const load: Load = async ({ params, fetch }) => {
}
try {
const follows = await follows_index({ type: params.type, username: params.handle! },
1, 10, fetch)
const follows = await profile_follows_index(
params.handle!,
params.type as "followers" | "following",
1,
fetch,
);
return { follows }
} catch (e) {