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:
@@ -1,13 +0,0 @@
|
||||
import type { Activity } from "$lib/models/activity";
|
||||
import { Collection, handleError, list } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<Activity>(event, Collection.activities);
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
32
web/src/routes/api/v1/activitypub/activity/[id]/+server.ts
Normal file
32
web/src/routes/api/v1/activitypub/activity/[id]/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Activity } from '$lib/models/activitypub/activity';
|
||||
import { Collection, handleError, show } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
import type { APActivity, APRoot } from 'activitypub-types';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const a = await show<Activity>(event, Collection.activitypub_activities)
|
||||
|
||||
const activity: APRoot<APActivity> = {
|
||||
id: a.iri,
|
||||
type: a.type,
|
||||
actor: a.actor,
|
||||
to: a.to,
|
||||
cc: a.cc,
|
||||
published: a.published,
|
||||
object: a.object
|
||||
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
], ...activity
|
||||
}, { headers })
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
28
web/src/routes/api/v1/activitypub/comment/[id]/+server.ts
Normal file
28
web/src/routes/api/v1/activitypub/comment/[id]/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const id = event.params.id;
|
||||
|
||||
try {
|
||||
const comment = await event.locals.pb.send("/activitypub/comment/" + id, {
|
||||
method: "GET",
|
||||
fetch: event.fetch,
|
||||
})
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
...comment
|
||||
}, { status: 200, headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
}
|
||||
28
web/src/routes/api/v1/activitypub/trail/[id]/+server.ts
Normal file
28
web/src/routes/api/v1/activitypub/trail/[id]/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const id = event.params.id;
|
||||
|
||||
try {
|
||||
const trail = await event.locals.pb.send("/activitypub/trail/" + id, {
|
||||
method: "GET",
|
||||
fetch: event.fetch,
|
||||
})
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json({
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
...trail
|
||||
}, { status: 200, headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
}
|
||||
65
web/src/routes/api/v1/activitypub/user/[handle]/+server.ts
Normal file
65
web/src/routes/api/v1/activitypub/user/[handle]/+server.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import type { UserAnonymous } from "$lib/models/user";
|
||||
import { splitUsername } from '$lib/util/activitypub_util';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { type APActor, type APRoot } from 'activitypub-types';
|
||||
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
|
||||
try {
|
||||
let fullUsername = event.params.handle;
|
||||
if (!fullUsername) {
|
||||
return json({ message: "Bad request" }, { status: 400 });
|
||||
}
|
||||
|
||||
fullUsername = fullUsername.replace(/^@/, "");
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
const user: UserAnonymous = await event.locals.pb.collection("users_anonymous").getOne(actor.user!)
|
||||
|
||||
|
||||
const id = `${env.ORIGIN}/api/v1/activitypub/user/${username}`
|
||||
|
||||
const r: APRoot<APActor & { publicKey: { id: string, owner: string, publicKeyPem: string } }> = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
id: id,
|
||||
type: "Person",
|
||||
inbox: id + '/inbox',
|
||||
outbox: id + '/outbox',
|
||||
summary: user.bio,
|
||||
name: actor.username,
|
||||
preferredUsername: user.username,
|
||||
followers: id + '/followers',
|
||||
following: id + '/following',
|
||||
url: `${env.ORIGIN}/profile/@${username}`,
|
||||
published: new Date(user.created ?? "").toISOString(),
|
||||
icon: {
|
||||
type: "Image",
|
||||
url: user.avatar ? `${env.ORIGIN}/api/v1/files/users/${user.id}/${user.avatar}` : undefined
|
||||
},
|
||||
publicKey: {
|
||||
id: id + '#main-key',
|
||||
owner: id,
|
||||
publicKeyPem: actor.public_key
|
||||
}
|
||||
|
||||
}
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json(r, { headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import type { Follow } from '$lib/models/follow';
|
||||
import { splitUsername } from '$lib/util/activitypub_util';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { APOrderedCollectionPage, APRoot } from 'activitypub-types';
|
||||
import type { ListResult } from 'pocketbase';
|
||||
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
|
||||
try {
|
||||
const page = event.url.searchParams.get("page") ?? "1"
|
||||
|
||||
const intPage = parseInt(page);
|
||||
|
||||
let fullUsername = event.params.handle;
|
||||
if (!fullUsername) {
|
||||
return json({ message: "Bad request" }, { status: 400 });
|
||||
}
|
||||
|
||||
fullUsername = fullUsername.replace(/^@/, "");
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
|
||||
const followers: ListResult<Follow> = await event.locals.pb.collection("follows").getList(intPage, 10, { sort: "-created", filter: `followee='${actor.id}'&&status='accepted'`, expand: "follower" })
|
||||
|
||||
const id = actor.iri;
|
||||
|
||||
const hasNextPage = intPage * 10 < followers.totalItems;
|
||||
|
||||
const followersPage: APRoot<APOrderedCollectionPage> = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
type: "OrderedCollectionPage",
|
||||
first: id + "/followers?page=1",
|
||||
...(intPage > 1 ? { prev: `${id}/followers?page=${intPage - 1}` } : {}),
|
||||
...(hasNextPage ? { next: `${id}/outbox?page=${intPage + 1}` } : {}),
|
||||
partOf: id + "/followers",
|
||||
totalItems: followers.totalItems,
|
||||
orderedItems: followers.items.map<string>(f => f.expand!.follower.iri)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json(followersPage, { headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import type { Follow } from '$lib/models/follow';
|
||||
import { splitUsername } from '$lib/util/activitypub_util';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { APOrderedCollectionPage, APRoot } from 'activitypub-types';
|
||||
import type { ListResult } from 'pocketbase';
|
||||
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
|
||||
try {
|
||||
const page = event.url.searchParams.get("page") ?? "1"
|
||||
|
||||
const intPage = parseInt(page);
|
||||
|
||||
let fullUsername = event.params.handle;
|
||||
if (!fullUsername) {
|
||||
return json({ message: "Bad request" }, { status: 400 });
|
||||
}
|
||||
|
||||
fullUsername = fullUsername.replace(/^@/, "");
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
|
||||
const followers: ListResult<Follow> = await event.locals.pb.collection("follows").getList(intPage, 10, { sort: "-created", filter: `follower='${actor.id}'&&status='accepted'`, expand: "followee" })
|
||||
|
||||
const id = actor.iri;
|
||||
|
||||
const hasNextPage = intPage * 10 < followers.totalItems;
|
||||
|
||||
const following: APRoot<APOrderedCollectionPage> = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
type: "OrderedCollectionPage",
|
||||
first: id + "/following?page=1",
|
||||
...(intPage > 1 ? { prev: `${id}/following?page=${intPage - 1}` } : {}),
|
||||
...(hasNextPage ? { next: `${id}/following?page=${intPage + 1}` } : {}),
|
||||
partOf: id + "/following",
|
||||
totalItems: followers.totalItems,
|
||||
orderedItems: followers.items.map<string>(f => f.expand!.followee.iri)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json(following, { headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { APActivity } from 'activitypub-types';
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
|
||||
|
||||
try {
|
||||
const activity: APActivity = await event.request.json()
|
||||
if (!activity.actor) {
|
||||
return json("Bad request", { status: 400 });
|
||||
}
|
||||
|
||||
const success = await event.locals.pb.send("/activitypub/activity/process", {
|
||||
method: "POST",
|
||||
fetch: event.fetch,
|
||||
headers: {
|
||||
'X-Forwarded-Path': event.url.pathname,
|
||||
'Content-Type': event.request.headers.get("content-type")!,
|
||||
signature: event.request.headers.get("signature")!,
|
||||
date: event.request.headers.get("date")!,
|
||||
digest: event.request.headers.get("digest")!
|
||||
},
|
||||
body: JSON.stringify(activity)
|
||||
})
|
||||
|
||||
if (success === false) {
|
||||
return json("Invalid header signature", { status: 400 });
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json("", { status: 200, headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
|
||||
import type { Activity } from '$lib/models/activitypub/activity';
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
|
||||
import { splitUsername } from '$lib/util/activitypub_util';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { APActivity, APOrderedCollectionPage, APRoot } from 'activitypub-types';
|
||||
import type { ListResult } from 'pocketbase';
|
||||
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
|
||||
|
||||
try {
|
||||
const safeSearchParams = RecordListOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
|
||||
|
||||
const page = safeSearchParams.page ?? 1
|
||||
const perPage = safeSearchParams.perPage ?? 10
|
||||
|
||||
let fullUsername = event.params.handle;
|
||||
if (!fullUsername) {
|
||||
return json({ message: "Bad request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
|
||||
const filter = `actor='${actor.iri}'&&type='Create'${safeSearchParams.filter ? '&&' + safeSearchParams.filter : ''}`
|
||||
const activities: ListResult<Activity> = await event.locals.pb.collection("activitypub_activities").getList(page, perPage, { sort: safeSearchParams.sort ?? "-created", filter })
|
||||
|
||||
const id = actor.iri;
|
||||
|
||||
const hasNextPage = page * perPage < activities.totalItems;
|
||||
|
||||
const outbox: APRoot<APOrderedCollectionPage> = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
],
|
||||
type: "OrderedCollectionPage",
|
||||
first: id + "/outbox?page=1",
|
||||
...(page > 1 ? { prev: `${id}/outbox?page=${page - 1}` } : {}),
|
||||
...(hasNextPage ? { next: `${id}/outbox?page=${page + 1}` } : {}),
|
||||
partOf: id + "/outbox",
|
||||
totalItems: activities.totalItems,
|
||||
orderedItems: activities.items.map<APActivity>(a => ({
|
||||
id: a.iri,
|
||||
actor: a.actor,
|
||||
type: a.type,
|
||||
to: a.to,
|
||||
cc: a.cc,
|
||||
published: a.published,
|
||||
object: a.object
|
||||
}))
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
headers.append("Content-Type", "application/activity+json")
|
||||
|
||||
return json(outbox, { headers });
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await event.locals.pb.collection('users').confirmPasswordReset(safeData.token, safeData.password, safeData.passwordConfirm);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await event.locals.pb.collection('users').authWithPassword(safeData.email ?? safeData.username!, data.password);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function GET(event: RequestEvent) {
|
||||
}
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export async function POST(event: RequestEvent) {
|
||||
)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await event.locals.pb.collection('users').requestPasswordReset(safeData.email);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await list<Category>(event, Collection.categories);
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,20 +2,70 @@ 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";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<Comment>(event, Collection.comments);
|
||||
for (const comment of r.items) {
|
||||
if (!comment.expand?.author) {
|
||||
comment.expand = {
|
||||
author: await event.locals.pb.collection('users_anonymous').getOne(comment.author)
|
||||
}
|
||||
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
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +74,6 @@ export async function PUT(event: RequestEvent) {
|
||||
const r = await create<Comment>(event, CommentCreateSchema, Collection.comments)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,9 @@ import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<Comment>(event, Collection.comments)
|
||||
if (!r.expand) {
|
||||
r.expand = {} as any
|
||||
}
|
||||
r.expand!.author = await event.locals.pb.collection('users_anonymous').getOne(r.author)
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +17,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<Comment>(event, CommentUpdateSchema, Collection.comments)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.comments)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { error, type RequestEvent } from "@sveltejs/kit";
|
||||
import { error, json, type RequestEvent } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
@@ -10,7 +10,15 @@ export async function GET(event: RequestEvent) {
|
||||
}).safeParse(event.params)
|
||||
|
||||
if (!safeParams.success) {
|
||||
throw error(400, safeParams.error)
|
||||
throw json({ message: safeParams.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const safeSearchParams = z.object({
|
||||
thumb: z.string().regex(/[0-9]*x[0-9]*[tbf]?/).optional()
|
||||
}).safeParse(Object.fromEntries(event.url.searchParams))
|
||||
|
||||
if (!safeSearchParams.success) {
|
||||
throw json({ message: safeSearchParams.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
@@ -20,7 +28,7 @@ export async function GET(event: RequestEvent) {
|
||||
parts.push(encodeURIComponent(safeParams.data.record));
|
||||
parts.push(encodeURIComponent(safeParams.data.file));
|
||||
|
||||
let fileURL = event.locals.pb.buildURL(parts.join("/"));
|
||||
let fileURL = event.locals.pb.buildURL(parts.join("/") + '?' + new URLSearchParams(safeSearchParams.data));
|
||||
|
||||
try {
|
||||
const r = await event.fetch(fileURL)
|
||||
|
||||
@@ -1,30 +1,78 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { FollowCreateSchema } from '$lib/models/api/follow_schema';
|
||||
import type { Follow } from '$lib/models/follow';
|
||||
import type { UserAnonymous } from '$lib/models/user';
|
||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||
import { APIError, 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";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<Follow>(event, Collection.follows);
|
||||
for (const follow of r.items) {
|
||||
const follower = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(follow.follower, { requestKey: null })
|
||||
const followee = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(follow.followee, { requestKey: null })
|
||||
follow.expand = {
|
||||
follower, followee
|
||||
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 }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const page = event.url.searchParams.get("page") ?? "1"
|
||||
const headers = new Headers()
|
||||
headers.set("Accept", 'application/ld+json')
|
||||
|
||||
const r = await event.fetch(actor[type as "followers" | "following"]! + '?' + new URLSearchParams({ page }), { headers })
|
||||
|
||||
if (!r.ok) {
|
||||
const errorResponse = await r.json()
|
||||
throw new ClientResponseError({ status: r.status, response: errorResponse });
|
||||
}
|
||||
const followers: APOrderedCollectionPage = await r.json()
|
||||
|
||||
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)
|
||||
}
|
||||
return json(r)
|
||||
|
||||
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await create<Follow>(event, FollowCreateSchema, Collection.follows)
|
||||
return json(r);
|
||||
const data = await event.request.json();
|
||||
const safeData = FollowCreateSchema.parse(data);
|
||||
|
||||
const followerActor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`user = '${event.locals.user.id}'`)
|
||||
const followeeActor: Actor = await event.locals.pb.collection("activitypub_actors").getOne(safeData.followee);
|
||||
|
||||
const follow = await event.locals.pb.collection("follows").create({ follower: followerActor.id, followee: followeeActor.id, status: followeeActor.isLocal ? "accepted" : "pending" })
|
||||
|
||||
return json(follow);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.follows)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Collection, handleError, show } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show(event, Collection.follow_counts)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ export async function PUT(event: RequestEvent) {
|
||||
const r = await create<Comment>(event, IntegrationCreateSchema, Collection.integrations)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await show<Integration>(event, Collection.integrations)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<Integration>(event, IntegrationUpdateSchema, Collection.integrations)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.integrations)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ export async function GET(event: RequestEvent) {
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { ListShareCreateSchema } from '$lib/models/api/list_share_schema';
|
||||
import type { ListShare } from '$lib/models/list_share';
|
||||
import type { User } from '$lib/models/user';
|
||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<ListShare>(event, Collection.list_share);
|
||||
|
||||
for (const share of r.items) {
|
||||
const anonymous_user = await event.locals.pb.collection('users_anonymous').getOne<User>(share.user)
|
||||
share.expand = {
|
||||
user: anonymous_user
|
||||
}
|
||||
}
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await create<ListShare>(event, ListShareCreateSchema, Collection.list_share)
|
||||
const data = await event.request.json();
|
||||
const safeData = ListShareCreateSchema.parse(data);
|
||||
|
||||
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${safeData.actor}`, { method: "GET", fetch: event.fetch, });
|
||||
safeData.actor = actor.id!;
|
||||
|
||||
const r = await event.locals.pb.collection(Collection.list_share).create<ListShare>(safeData)
|
||||
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await show<ListShare>(event, Collection.list_share)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<ListShare>(event, ListShareUpdateSchema, Collection.list_share)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.list_share)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,11 @@ export async function GET(event: RequestEvent) {
|
||||
if (!t.author || !event.locals.pb.authStore.record) {
|
||||
continue;
|
||||
}
|
||||
if (!t.expand) {
|
||||
t.expand = {}
|
||||
}
|
||||
t.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(t.author);
|
||||
|
||||
}
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +23,6 @@ export async function PUT(event: RequestEvent) {
|
||||
const r = await create<List>(event, ListCreateSchema, Collection.lists)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,82 @@
|
||||
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 { json, type RequestEvent } from "@sveltejs/kit";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<List>(event, Collection.lists)
|
||||
if (!r.expand) {
|
||||
r.expand = {} as any
|
||||
}
|
||||
r.expand!.author = await event.locals.pb.collection('users_anonymous').getOne(r.author!)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +85,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<List>(event, ListUpdateSchema, Collection.lists)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +94,7 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.lists)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await upload<List>(event, Collection.lists);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
12
web/src/routes/api/v1/list/form/+server.ts
Normal file
12
web/src/routes/api/v1/list/form/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { List } from '$lib/models/list';
|
||||
import { Collection, handleError, uploadCreate } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await uploadCreate<List>(event, Collection.lists)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
12
web/src/routes/api/v1/list/form/[id]/+server.ts
Normal file
12
web/src/routes/api/v1/list/form/[id]/+server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { List } from "$lib/models/list";
|
||||
import { Collection, handleError, uploadUpdate } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const r = await uploadUpdate<List>(event, Collection.lists)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Notification } from '$lib/models/notification';
|
||||
import type { UserAnonymous } from '$lib/models/user';
|
||||
import { Collection, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
@@ -7,15 +6,8 @@ export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<Notification>(event, Collection.notifications);
|
||||
|
||||
for (const notification of r.items) {
|
||||
const recipient = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(notification.recipient, { requestKey: null })
|
||||
const author = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(notification.author, { requestKey: null })
|
||||
notification.expand = {
|
||||
recipient, author
|
||||
}
|
||||
}
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<Comment>(event, NotificationUpdateSchema, Collection.notifications)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
35
web/src/routes/api/v1/profile/[handle]/+server.ts
Normal file
35
web/src/routes/api/v1/profile/[handle]/+server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Profile } from '$lib/models/profile';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const { actor, error } = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}&follows=true`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const profile: Profile = {
|
||||
id: actor.id!,
|
||||
username: actor.username,
|
||||
preferredUsername: actor.preferred_username,
|
||||
acct: handle,
|
||||
createdAt: actor.published ?? "",
|
||||
bio: actor.summary ?? "",
|
||||
uri: actor.iri,
|
||||
followers: actor.followerCount ?? 0,
|
||||
following: actor.followingCount ?? 0,
|
||||
icon: actor.icon ?? "",
|
||||
error
|
||||
}
|
||||
|
||||
return json({ profile, actor: actor })
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message == "fetch failed") {
|
||||
return error(404, "Not found")
|
||||
}
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
43
web/src/routes/api/v1/profile/[handle]/lists/+server.ts
Normal file
43
web/src/routes/api/v1/profile/[handle]/lists/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { TrailSearchResult } from '$lib/models/trail';
|
||||
import type { ListSearchResult } from '$lib/stores/search_store';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { SearchResponse } from 'meilisearch';
|
||||
import { ClientResponseError } from 'pocketbase';
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const data = await event.request.json()
|
||||
|
||||
let r: SearchResponse<ListSearchResult>;
|
||||
if (actor.isLocal) {
|
||||
r = await event.locals.ms.index("lists").search(data.q, { ...data.options, filter: `author = ${actor.id}` });
|
||||
} else {
|
||||
const origin = new URL(actor.iri).origin
|
||||
const url = `${origin}/api/v1/profile/${actor.username}/lists?` + event.url.searchParams
|
||||
const response = await event.fetch(url, { method: 'POST', body: JSON.stringify(data) })
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse });
|
||||
}
|
||||
r = await response.json()
|
||||
|
||||
r.hits.forEach(h => {
|
||||
h.avatar = h.avatar ? `${origin}/api/v1/files/lists/${h.id}/${h.avatar}` : undefined;
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
49
web/src/routes/api/v1/profile/[handle]/stats/+server.ts
Normal file
49
web/src/routes/api/v1/profile/[handle]/stats/+server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
|
||||
import type { SummitLog } from '$lib/models/summit_log';
|
||||
import { Collection, handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { ClientResponseError, type ListResult } from 'pocketbase';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
|
||||
|
||||
let summitLogs: ListResult<SummitLog>;
|
||||
if (actor.isLocal) {
|
||||
summitLogs = await event.locals.pb.collection(Collection.summit_logs)
|
||||
.getList<SummitLog>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams, filter: `author='${actor.id}'` })
|
||||
} else {
|
||||
const origin = new URL(actor.iri).origin
|
||||
const summitLogURL = `${origin}/api/v1/profile/${actor.username}/stats?` + event.url.searchParams
|
||||
const response = await event.fetch(summitLogURL, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse });
|
||||
}
|
||||
summitLogs = await response.json()
|
||||
|
||||
summitLogs.items.forEach(i => {
|
||||
i.photos = i.photos.map(p =>
|
||||
`${origin}/api/v1/files/summit_logs/${i.id}/${p}`
|
||||
)
|
||||
if (i.gpx) {
|
||||
i.gpx = `${origin}/api/v1/files/summit_logs/${i.id}/${i.gpx}`
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return json(summitLogs)
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
48
web/src/routes/api/v1/profile/[handle]/timeline/+server.ts
Normal file
48
web/src/routes/api/v1/profile/[handle]/timeline/+server.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { RecordListOptionsSchema } from '$lib/models/api/base_schema';
|
||||
import { type TimelineItem } from '$lib/models/timeline';
|
||||
import { Collection, handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { ClientResponseError, type ListResult } from 'pocketbase';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = RecordListOptionsSchema.parse(searchParams);
|
||||
|
||||
let timeline: ListResult<TimelineItem>;
|
||||
if (actor.isLocal) {
|
||||
timeline = await event.locals.pb.collection(Collection.timeline)
|
||||
.getList<TimelineItem>(safeSearchParams.page, safeSearchParams.perPage, { ...safeSearchParams, filter: `author='${actor.iri}'` })
|
||||
} else {
|
||||
const origin = new URL(actor.iri).origin
|
||||
const timelineURL = `${origin}/api/v1/profile/${actor.username}/timeline?` + event.url.searchParams
|
||||
|
||||
const response = await event.fetch(timelineURL, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse });
|
||||
}
|
||||
timeline = await response.json()
|
||||
|
||||
timeline.items.forEach(i => {
|
||||
i.photos = i. photos.map(p =>
|
||||
`${origin}/api/v1/files/${i.type}s/${i.id}/${p}`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return json(timeline)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
43
web/src/routes/api/v1/profile/[handle]/trails/+server.ts
Normal file
43
web/src/routes/api/v1/profile/[handle]/trails/+server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { TrailSearchResult } from '$lib/models/trail';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { SearchResponse } from 'meilisearch';
|
||||
import { ClientResponseError } from 'pocketbase';
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
const handle = event.params.handle;
|
||||
if (!handle) {
|
||||
return error(400, { message: "Bad request" })
|
||||
}
|
||||
|
||||
try {
|
||||
const {actor, error} = await event.locals.pb.send(`/activitypub/actor?resource=acct:${handle}`, { method: "GET", fetch: event.fetch, });
|
||||
|
||||
const data = await event.request.json()
|
||||
|
||||
let r: SearchResponse<TrailSearchResult>;
|
||||
if (actor.isLocal) {
|
||||
r = await event.locals.ms.index("trails").search(data.q, { ...data.options, filter: `author = ${actor.id}` });
|
||||
} else {
|
||||
const origin = new URL(actor.iri).origin
|
||||
const url = `${origin}/api/v1/profile/${actor.username}/trails?` + event.url.searchParams
|
||||
const response = await event.fetch(url, { method: 'POST', body: JSON.stringify(data) })
|
||||
|
||||
if (!response.ok) {
|
||||
const errorResponse = await response.json()
|
||||
throw new ClientResponseError({ status: response.status, response: errorResponse });
|
||||
}
|
||||
r = await response.json()
|
||||
|
||||
r.hits.forEach(h => {
|
||||
h.thumbnail = `${origin}/api/v1/files/trails/${h.id}/${h.thumbnail}`;
|
||||
h.domain = actor.domain
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ export async function POST(event: RequestEvent) {
|
||||
const data = await event.request.json()
|
||||
|
||||
try {
|
||||
const r = await event.locals.ms.index(event.params.index as string).search(data.q, data.options);
|
||||
const r = await event.locals.ms.index(event.params.index as string).search(data.q, data.options);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
|
||||
|
||||
throw error(e.httpStatus, e)
|
||||
}
|
||||
}
|
||||
46
web/src/routes/api/v1/search/actor/+server.ts
Normal file
46
web/src/routes/api/v1/search/actor/+server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { splitUsername } from '$lib/util/activitypub_util';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { error, json, type RequestEvent } from '@sveltejs/kit';
|
||||
import { ClientResponseError, type ListResult } from "pocketbase"
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
|
||||
if (!event.url.searchParams.has("q")) {
|
||||
throw new ClientResponseError({ status: 400, response: "Bad request" });
|
||||
|
||||
}
|
||||
const q = event.url.searchParams.get("q")
|
||||
|
||||
const [user, domain] = splitUsername(q!)
|
||||
|
||||
let filter = `username~'${user}'`;
|
||||
|
||||
if (event.url.searchParams.get("includeSelf") == "false" && event.locals.pb.authStore.record) {
|
||||
filter += `&& id != "${event.locals.pb.authStore.record.actor}"`
|
||||
}
|
||||
|
||||
const response = await event.locals.pb.collection("activitypub_actors").getList(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, });
|
||||
|
||||
if (!response.items.find(i => i.iri == actor.iri)) {
|
||||
response.items.push(actor)
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
|
||||
return json({ items: response.items })
|
||||
|
||||
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message == "fetch failed") {
|
||||
return error(404, "Not found")
|
||||
}
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await show<Settings>(event, Collection.settings)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<Settings>(event, SettingsCreateSchema, Collection.settings)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.settings)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,90 @@ 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";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<SummitLog>(event, Collection.summit_logs);
|
||||
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)
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +94,12 @@ export async function PUT(event: RequestEvent) {
|
||||
const r = await create<SummitLog>(event, SummitLogCreateSchema, Collection.summit_logs)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function removeTimeFromDates(logs: SummitLog[]) {
|
||||
logs.forEach(l => l.date = l.date.substring(0, 10));
|
||||
|
||||
}
|
||||
@@ -7,15 +7,9 @@ import { error, json, type RequestEvent } from "@sveltejs/kit";
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<SummitLog>(event, Collection.summit_logs)
|
||||
|
||||
if (!r.expand) {
|
||||
r.expand = {} as any
|
||||
}
|
||||
r.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(r.author!);
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +18,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<SummitLog>(event, SummitLogUpdateSchema, Collection.summit_logs)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +27,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.summit_logs)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
14
web/src/routes/api/v1/summit-log/form/+server.ts
Normal file
14
web/src/routes/api/v1/summit-log/form/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { SummitLog } from '$lib/models/summit_log';
|
||||
import { Collection, handleError, uploadCreate } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await uploadCreate<SummitLog>(event, Collection.summit_logs)
|
||||
r.date = r.date?.substring(0, 10) ?? "";
|
||||
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
13
web/src/routes/api/v1/summit-log/form/[id]/+server.ts
Normal file
13
web/src/routes/api/v1/summit-log/form/[id]/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import { Collection, handleError, uploadUpdate } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const r = await uploadUpdate<SummitLog>(event, Collection.summit_logs)
|
||||
r.date = r.date?.substring(0, 10) ?? "";
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@ export async function PUT(event: RequestEvent) {
|
||||
const r = await create<Tag>(event, TagCreateSchema, Collection.tags)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await show<Tag>(event, Collection.tags)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<Tag>(event, TagUpdateSchema, Collection.tags)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.tags)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
23
web/src/routes/api/v1/trail-like/+server.ts
Normal file
23
web/src/routes/api/v1/trail-like/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { TrailLikeCreateSchema } from '$lib/models/api/trail_like_schema';
|
||||
import type { TrailLike } from '$lib/models/trail_like';
|
||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<TrailLike>(event, Collection.trail_share);
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await create<TrailLike>(event, TrailLikeCreateSchema, Collection.trail_like)
|
||||
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
21
web/src/routes/api/v1/trail-like/[id]/+server.ts
Normal file
21
web/src/routes/api/v1/trail-like/[id]/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TrailLike } from "$lib/models/trail_like";
|
||||
import { Collection, handleError, remove, show } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<TrailLike>(event, Collection.trail_like)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(event: RequestEvent) {
|
||||
try {
|
||||
const r = await remove(event, Collection.trail_like)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
21
web/src/routes/api/v1/trail-like/delete/+server.ts
Normal file
21
web/src/routes/api/v1/trail-like/delete/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { TrailLikeCreateSchema } from '$lib/models/api/trail_like_schema';
|
||||
import { Collection, handleError } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
|
||||
// this route exists so we can delete a like without knowing its ID
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
|
||||
const data = await event.request.json();
|
||||
const safeData = TrailLikeCreateSchema.parse(data);
|
||||
|
||||
const like = await event.locals.pb.collection(Collection.trail_like).getFirstListItem(`actor='${safeData.actor}'&&trail='${safeData.trail}'`)
|
||||
|
||||
const r = await event.locals.pb.collection(Collection.trail_like).delete(like.id)
|
||||
|
||||
return json({ 'acknowledged': r })
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
import type { Actor } from '$lib/models/activitypub/actor';
|
||||
import { TrailShareCreateSchema } from '$lib/models/api/trail_share_schema';
|
||||
import type { TrailShare } from '$lib/models/trail_share';
|
||||
import type { UserAnonymous } from '$lib/models/user';
|
||||
import { Collection, handleError, list, create } from '$lib/util/api_util';
|
||||
import { Collection, create, handleError, list } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<TrailShare>(event, Collection.trail_share);
|
||||
for (const share of r.items) {
|
||||
const anonymous_user = await event.locals.pb.collection('users_anonymous').getOne<UserAnonymous>(share.user)
|
||||
share.expand = {
|
||||
user: anonymous_user
|
||||
}
|
||||
}
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
handleError(e)
|
||||
@@ -21,9 +15,16 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await create<TrailShare>(event, TrailShareCreateSchema, Collection.trail_share)
|
||||
const data = await event.request.json();
|
||||
const safeData = TrailShareCreateSchema.parse(data);
|
||||
|
||||
const { actor }: { actor: Actor } = await event.locals.pb.send(`/activitypub/actor?iri=${safeData.actor}`, { method: "GET", fetch: event.fetch, });
|
||||
safeData.actor = actor.id!;
|
||||
|
||||
const r = await event.locals.pb.collection(Collection.trail_share).create<TrailShare>(safeData)
|
||||
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await show<TrailShare>(event, Collection.trail_share)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<TrailShare>(event, TrailShareUpdateSchema, Collection.trail_share)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.trail_share)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,23 +19,23 @@ export async function GET(event: RequestEvent) {
|
||||
}
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
try {
|
||||
const r = await create<Trail>(event, TrailCreateSchema, Collection.trails)
|
||||
enrichRecord(r);
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
function enrichRecord(r: Trail) {
|
||||
r.date = r.date?.substring(0, 10) ?? "";
|
||||
for (const log of r.expand?.summit_logs ?? []) {
|
||||
for (const log of r.expand?.summit_logs_via_trail ?? []) {
|
||||
log.date = log.date.substring(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,137 @@
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
||||
import { error, json, type RequestEvent } from "@sveltejs/kit";
|
||||
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 { json, type RequestEvent } from "@sveltejs/kit";
|
||||
import type PocketBase from "pocketbase";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<Trail>(event, Collection.trails)
|
||||
// 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")
|
||||
|
||||
if (event.locals.pb.authStore.record) {
|
||||
if (!r.expand) {
|
||||
r.expand = {} as any
|
||||
const safeSearchParams = RecordOptionsSchema.parse(Object.fromEntries(event.url.searchParams));
|
||||
|
||||
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
|
||||
} 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?.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;
|
||||
}
|
||||
r.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(r.author!);
|
||||
}
|
||||
|
||||
// remove time from dates
|
||||
await enrichRecord(event.locals.pb, r);
|
||||
await enrichRecord(event.locals.pb, t);
|
||||
|
||||
// sort waypoints by distance
|
||||
r.expand?.waypoints?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
|
||||
return json(r)
|
||||
t.expand?.waypoints?.sort((a, b) => (a.distance_from_start ?? 0) - (b.distance_from_start ?? 0))
|
||||
return json(t)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +141,7 @@ export async function POST(event: RequestEvent) {
|
||||
await enrichRecord(event.locals.pb, r)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +150,7 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.trails)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,14 +158,7 @@ export async function DELETE(event: RequestEvent) {
|
||||
|
||||
async function enrichRecord(pb: PocketBase, r: Trail) {
|
||||
r.date = r.date?.substring(0, 10) ?? "";
|
||||
for (const log of r.expand?.summit_logs ?? []) {
|
||||
for (const log of r.expand?.summit_logs_via_trail ?? []) {
|
||||
log.date = log.date.substring(0, 10);
|
||||
|
||||
if (!log.expand) {
|
||||
log.expand = {} as any;
|
||||
}
|
||||
if (log.author) {
|
||||
log.expand!.author = await pb.collection("users_anonymous").getOne(log.author);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,9 @@ export async function GET(event: RequestEvent) {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const r = await event.locals.pb.collection('trails_bounding_box').getOne<TrailBoundingBox>(event.locals.pb.authStore.record!.id)
|
||||
const r = await event.locals.pb.collection('trails_bounding_box').getOne<TrailBoundingBox>(event.locals.user.actor!)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@ export async function POST(event: RequestEvent) {
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ export async function GET(event: RequestEvent) {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const r = await event.locals.pb.collection('trails_filter').getOne<TrailFilterValues>(event.locals.pb.authStore.record!.id)
|
||||
const r = await event.locals.pb.collection('trails_filter').getOne<TrailFilterValues>(event.locals.user.actor)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
20
web/src/routes/api/v1/trail/form/+server.ts
Normal file
20
web/src/routes/api/v1/trail/form/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Trail } from '$lib/models/trail';
|
||||
import { Collection, handleError, uploadCreate } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await uploadCreate<Trail>(event, Collection.trails)
|
||||
enrichRecord(r);
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
function enrichRecord(r: Trail) {
|
||||
r.date = r.date?.substring(0, 10) ?? "";
|
||||
for (const log of r.expand?.summit_logs_via_trail ?? []) {
|
||||
log.date = log.date.substring(0, 10);
|
||||
}
|
||||
}
|
||||
21
web/src/routes/api/v1/trail/form/[id]/+server.ts
Normal file
21
web/src/routes/api/v1/trail/form/[id]/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { Collection, handleError, uploadUpdate } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const r = await uploadUpdate<Trail>(event, Collection.trails)
|
||||
enrichRecord(r);
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function enrichRecord(r: Trail) {
|
||||
r.date = r.date?.substring(0, 10) ?? "";
|
||||
for (const log of r.expand?.summit_logs_via_trail ?? []) {
|
||||
log.date = log.date.substring(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TrailRecommendSchema } from '$lib/models/api/trail_schema';
|
||||
import type { Trail } from '$lib/models/trail';
|
||||
import { handleError } from '$lib/util/api_util';
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
@@ -7,27 +6,13 @@ export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const searchParams = Object.fromEntries(event.url.searchParams);
|
||||
const safeSearchParams = TrailRecommendSchema.parse(searchParams);
|
||||
|
||||
const r = await event.locals.pb.send("/trail/recommend?" + new URLSearchParams({
|
||||
size: safeSearchParams.size?.toString() ?? ""
|
||||
}), {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const result: Trail[] = r;
|
||||
const numberOfTrails = (await event.locals.ms.index("trails").search("", {limit: 1})).estimatedTotalHits
|
||||
const randomOffset = (safeSearchParams.size ?? 0) > numberOfTrails ? 0 : Math.floor(Math.random() * (numberOfTrails - 1) + 1)
|
||||
const response = await event.locals.ms.index("trails").search("", {limit: safeSearchParams.size, offset: randomOffset})
|
||||
|
||||
for (const t of result) {
|
||||
if (!t.author || !event.locals.pb.authStore.record) {
|
||||
continue;
|
||||
}
|
||||
if (!t.expand) {
|
||||
t.expand = {} as any
|
||||
}
|
||||
t.expand!.author = await event.locals.pb.collection("users_anonymous").getOne(t.author);
|
||||
}
|
||||
|
||||
return json(result)
|
||||
return json(response.hits)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import GPX from "$lib/models/gpx/gpx";
|
||||
import { haversineDistance } from "$lib/models/gpx/utils";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import type { Trail, TrailSearchResult } from "$lib/models/trail";
|
||||
import { trails_create } from "$lib/stores/trail_store";
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { fromFile, gpx2trail } from "$lib/util/gpx_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
import type MeiliSearch from "meilisearch";
|
||||
import type { Hits } from "meilisearch";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
import type PocketBase from 'pocketbase';
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
@@ -28,14 +29,14 @@ export async function PUT(event: RequestEvent) {
|
||||
|
||||
const ignoreDuplicates = data.get("ignoreDuplicates") === "true"
|
||||
if (!ignoreDuplicates) {
|
||||
let duplicate: Trail | null = null;
|
||||
let duplicate: TrailSearchResult | null = null;
|
||||
try {
|
||||
duplicate = await findDuplicate(event.locals.pb, trail)
|
||||
duplicate = await findDuplicate(event.locals.ms, trail)
|
||||
} catch (e: any) {
|
||||
throw new ClientResponseError({ status: 500, response: { message: "Error checking for duplicates" } })
|
||||
}
|
||||
if (duplicate !== null) {
|
||||
throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name }, })
|
||||
throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name, domain: `${duplicate.author_name}${duplicate.domain ? '@' + duplicate.domain : ''}` }, })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +57,19 @@ export async function PUT(event: RequestEvent) {
|
||||
trail = await trails_create(trail, [], gpxFile, event.fetch, event.locals.user);
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
return json(trail);
|
||||
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function findDuplicate(pb: PocketBase, t1: Trail) {
|
||||
const trails: Trail[] = await pb.collection('trails').getFullList({ requestKey: null });
|
||||
async function findDuplicate(ms: MeiliSearch, t1: Trail) {
|
||||
const response = await ms.index("trails").search("", {});
|
||||
|
||||
const trails: TrailSearchResult[] = response.hits as Hits<TrailSearchResult>
|
||||
|
||||
const distanceThreshold = 100;
|
||||
const elevationThreshhold = 50;
|
||||
@@ -76,7 +79,7 @@ async function findDuplicate(pb: PocketBase, t1: Trail) {
|
||||
const lengthDifference = Math.abs((t1.distance ?? 0) - (t2.distance ?? 0));
|
||||
const elevationGainDifference = Math.abs((t1.elevation_gain ?? 0) - (t2.elevation_gain ?? 0));
|
||||
const elevationLossDifference = Math.abs((t1.elevation_loss ?? 0) - (t2.elevation_loss ?? 0));
|
||||
const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2.lat ?? 0, t2.lon ?? 0)
|
||||
const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2._geo.lat ?? 0, t2._geo.lng ?? 0)
|
||||
|
||||
if (lengthDifference < lengthThreshhold && elevationGainDifference < elevationThreshhold && elevationLossDifference < elevationThreshhold && startpointDifference < distanceThreshold) {
|
||||
return t2
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function PUT(event: RequestEvent) {
|
||||
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(event: RequestEvent) {
|
||||
return json(r);
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.users)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await list<Waypoint>(event, Collection.waypoints);
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,6 @@ export async function PUT(event: RequestEvent) {
|
||||
const r = await create<Waypoint>(event, WaypointCreateSchema, Collection.waypoints)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export async function GET(event: RequestEvent) {
|
||||
const r = await show<Waypoint>(event, Collection.waypoints)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await update<Waypoint>(event, WaypointUpdateSchema, Collection.waypoints)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ export async function DELETE(event: RequestEvent) {
|
||||
const r = await remove(event, Collection.waypoints)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e)
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ export async function POST(event: RequestEvent) {
|
||||
const r = await upload<Waypoint>(event, Collection.waypoints);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
throw handleError(e);
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user