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:
2
web/package-lock.json
generated
2
web/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
56
web/src/routes/api/v1/profile/[handle]/follows/+server.ts
Normal file
56
web/src/routes/api/v1/profile/[handle]/follows/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
56
web/src/routes/api/v1/trail/[id]/comment/+server.ts
Normal file
56
web/src/routes/api/v1/trail/[id]/comment/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user