kopia lustrzana https://github.com/cloudflare/wildebeest
commit
0fd30524f5
|
@ -0,0 +1,59 @@
|
|||
// https://docs.joinmastodon.org/methods/accounts/#get
|
||||
|
||||
import { actorURL, getActorById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
|
||||
import type { Handle } from 'wildebeest/backend/src/utils/parse'
|
||||
import { queryAcct } from 'wildebeest/backend/src/webfinger/index'
|
||||
import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
|
||||
import { MastodonAccount } from '../types'
|
||||
|
||||
export async function getAccount(domain: string, accountId: string, db: D1Database): Promise<MastodonAccount | null> {
|
||||
const handle = parseHandle(accountId)
|
||||
|
||||
if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
|
||||
// Retrieve the statuses from a local user
|
||||
return getLocalAccount(domain, db, handle)
|
||||
} else if (handle.domain !== null) {
|
||||
// Retrieve the statuses of a remote actor
|
||||
const acct = `${handle.localPart}@${handle.domain}`
|
||||
return getRemoteAccount(handle, acct)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteAccount(handle: Handle, acct: string): Promise<MastodonAccount | null> {
|
||||
// TODO: using webfinger isn't the optimal implementation. We could cache
|
||||
// the object in D1 and directly query the remote API, indicated by the actor's
|
||||
// url field. For now, let's keep it simple.
|
||||
const actor = await queryAcct(handle.domain!, acct)
|
||||
if (actor === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await loadExternalMastodonAccount(acct, actor, true)
|
||||
}
|
||||
|
||||
async function getLocalAccount(domain: string, db: D1Database, handle: Handle): Promise<MastodonAccount | null> {
|
||||
const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart)
|
||||
|
||||
const actor = await getActorById(db, actorId)
|
||||
if (actor === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await loadLocalMastodonAccount(db, actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* checks if a domain is a localhost one ('localhost' or '127.x.x.x') and
|
||||
* in that case replaces it with '0.0.0.0' (which is what we use for our local data)
|
||||
*
|
||||
* Note: only needed for local development
|
||||
*
|
||||
* @param domain the potentially localhost domain
|
||||
* @returns the adjusted domain if it was a localhost one, the original domain otherwise
|
||||
*/
|
||||
function adjustLocalHostDomain(domain: string) {
|
||||
return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0')
|
||||
}
|
|
@ -231,7 +231,7 @@ export async function sanitizeObjectProperties(properties: unknown): Promise<APO
|
|||
* See https://docs.joinmastodon.org/spec/activitypub/#sanitization
|
||||
*/
|
||||
export async function sanitizeContent(unsafeContent: string): Promise<string> {
|
||||
return await contentRewriter.transform(new Response(unsafeContent)).text()
|
||||
return await getContentRewriter().transform(new Response(unsafeContent)).text()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -240,11 +240,12 @@ export async function sanitizeContent(unsafeContent: string): Promise<string> {
|
|||
* This sanitization removes all HTML elements from the string leaving only the text content.
|
||||
*/
|
||||
export async function sanitizeName(unsafeName: string): Promise<string> {
|
||||
return await nameRewriter.transform(new Response(unsafeName)).text()
|
||||
return await getNameRewriter().transform(new Response(unsafeName)).text()
|
||||
}
|
||||
|
||||
const contentRewriter = new HTMLRewriter()
|
||||
contentRewriter.on('*', {
|
||||
function getContentRewriter() {
|
||||
const contentRewriter = new HTMLRewriter()
|
||||
contentRewriter.on('*', {
|
||||
element(el) {
|
||||
if (!['p', 'span', 'br', 'a'].includes(el.tagName)) {
|
||||
el.tagName = 'p'
|
||||
|
@ -258,11 +259,16 @@ contentRewriter.on('*', {
|
|||
el.setAttribute('class', sanitizedClasses.join(' '))
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
return contentRewriter
|
||||
}
|
||||
|
||||
const nameRewriter = new HTMLRewriter()
|
||||
nameRewriter.on('*', {
|
||||
function getNameRewriter() {
|
||||
const nameRewriter = new HTMLRewriter()
|
||||
nameRewriter.on('*', {
|
||||
element(el) {
|
||||
el.removeAndKeepContent()
|
||||
},
|
||||
})
|
||||
})
|
||||
return nameRewriter
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Link, useNavigate } from '@builder.io/qwik-city'
|
|||
import { formatTimeAgo } from '~/utils/dateTime'
|
||||
import { Avatar } from '../avatar'
|
||||
import type { Account, MastodonStatus } from '~/types'
|
||||
import styles from './index.scss?inline'
|
||||
import styles from '../../utils/innerHtmlContent.scss?inline'
|
||||
import { MediaGallery } from '../MediaGallery.tsx'
|
||||
|
||||
type Props = {
|
||||
|
@ -45,7 +45,7 @@ export default component$((props: Props) => {
|
|||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="leading-relaxed status-content" dangerouslySetInnerHTML={status.content} />
|
||||
<div class="leading-relaxed inner-html-content" dangerouslySetInnerHTML={status.content} />
|
||||
</div>
|
||||
|
||||
<MediaGallery medias={status.media_attachments} />
|
||||
|
|
|
@ -12,7 +12,7 @@ import { MediaGallery } from '~/components/MediaGallery.tsx'
|
|||
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
||||
|
||||
export const statusLoader = loader$<
|
||||
{ DATABASE: D1Database; domain: string },
|
||||
{ DATABASE: D1Database },
|
||||
Promise<{ status: MastodonStatus; context: StatusContext }>
|
||||
>(async ({ request, html, platform, params }) => {
|
||||
const domain = new URL(request.url).hostname
|
||||
|
|
|
@ -1,23 +1,131 @@
|
|||
import { component$ } from '@builder.io/qwik'
|
||||
import { loader$ } from '@builder.io/qwik-city'
|
||||
import { $, component$, useStyles$ } from '@builder.io/qwik'
|
||||
import { loader$, useNavigate } from '@builder.io/qwik-city'
|
||||
import { MastodonAccount } from 'wildebeest/backend/src/types'
|
||||
import StickyHeader from '~/components/StickyHeader/StickyHeader'
|
||||
import { formatDateTime } from '~/utils/dateTime'
|
||||
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
||||
import { formatRoundedNumber } from '~/utils/numbers'
|
||||
import styles from '../../../utils/innerHtmlContent.scss?inline'
|
||||
import { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
|
||||
|
||||
export const accountLoader = loader$(({ request, html }) => {
|
||||
const params = new URL(request.url).searchParams
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const accountId = params.get('accountId')
|
||||
export async function getAccountDetails(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
accountId: string
|
||||
): Promise<MastodonAccount | null> {
|
||||
return await getAccount(domain, accountId, db)
|
||||
}
|
||||
|
||||
html(404, getNotFoundHtml())
|
||||
export const accountLoader = loader$<{ DATABASE: D1Database }, Promise<MastodonAccount>>(
|
||||
async ({ platform, request, html }) => {
|
||||
const url = new URL(request.url)
|
||||
const domain = url.hostname
|
||||
const accountId = url.pathname.split('/')[1]
|
||||
|
||||
// TODO: retrieve the account details from the backend
|
||||
const accountDetails = null
|
||||
const account = await getAccountDetails(domain, platform.DATABASE, accountId)
|
||||
|
||||
return accountDetails
|
||||
})
|
||||
if (!account) {
|
||||
throw html(404, getNotFoundHtml())
|
||||
}
|
||||
|
||||
return account
|
||||
}
|
||||
)
|
||||
|
||||
export default component$(() => {
|
||||
const accountDetails = accountLoader.use()
|
||||
useStyles$(styles)
|
||||
const nav = useNavigate()
|
||||
|
||||
// TODO: Implement the account view
|
||||
return <>{accountDetails.value && <div>account details</div>}</>
|
||||
const accountDetails = accountLoader.use().value
|
||||
|
||||
const goBack = $(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back()
|
||||
} else {
|
||||
nav('/explore')
|
||||
}
|
||||
})
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: 'Joined',
|
||||
value: formatDateTime(accountDetails.created_at, false),
|
||||
},
|
||||
...accountDetails.fields,
|
||||
]
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Posts',
|
||||
value: formatRoundedNumber(accountDetails.statuses_count),
|
||||
},
|
||||
{
|
||||
name: 'Following',
|
||||
value: formatRoundedNumber(accountDetails.following_count),
|
||||
},
|
||||
{
|
||||
name: 'Followers',
|
||||
value: formatRoundedNumber(accountDetails.followers_count),
|
||||
},
|
||||
]
|
||||
|
||||
const accountDomain = getAccountDomain(accountDetails)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StickyHeader>
|
||||
<div class="flex justify-between items-center xl:rounded-t header bg-wildebeest-700">
|
||||
<button class="text-semi no-underline text-wildebeest-vibrant-400 bg-transparent p-4" onClick$={goBack}>
|
||||
<i class="fa fa-chevron-left mr-2 w-3 inline-block" />
|
||||
<span class="hover:underline">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
</StickyHeader>
|
||||
<div class="relative mb-16">
|
||||
<img
|
||||
src={accountDetails.header}
|
||||
alt={`Header of ${accountDetails.display_name}`}
|
||||
class="w-full h-40 object-cover bg-wildebeest-500"
|
||||
/>
|
||||
<img
|
||||
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
|
||||
src={accountDetails.avatar}
|
||||
alt={`Avatar of ${accountDetails.display_name}`}
|
||||
/>
|
||||
</div>
|
||||
<div class="px-5">
|
||||
<h2 class="font-bold">{accountDetails.display_name}</h2>
|
||||
<span class="block my-1 text-wildebeest-400">
|
||||
@{accountDetails.acct}
|
||||
{accountDomain && `@${accountDomain}`}
|
||||
</span>
|
||||
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.note} />
|
||||
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
|
||||
{fields.map(({ name, value }) => (
|
||||
<div class="border-b border-wildebeest-600 p-3 text-sm" key={name}>
|
||||
<dt class="uppercase font-semibold text-wildebeest-500 opacity-80 mb-1">{name}</dt>
|
||||
<dd class="inner-html-content opacity-80 text-wildebeest-200" dangerouslySetInnerHTML={value}></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div class="pb-4 flex flex-wrap gap-5">
|
||||
{stats.map(({ name, value }) => (
|
||||
<div class="flex gap-1" key={name}>
|
||||
<span class="font-semibold">{value}</span>
|
||||
<span class="text-wildebeest-500">{name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export function getAccountDomain(account: MastodonAccount): string | null {
|
||||
try {
|
||||
const url = new URL(account.url)
|
||||
return url.hostname || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,9 +40,9 @@ export const formatTimeAgo = (date: Date) => {
|
|||
return `${roundTo(YEAR)}y`
|
||||
}
|
||||
|
||||
export const formatDateTime = (isoString: string) => {
|
||||
export const formatDateTime = (isoString: string, includeTime = true) => {
|
||||
const date = new Date(isoString)
|
||||
const dateFormatter = Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const timeFormatter = Intl.DateTimeFormat('en', { timeStyle: 'short' })
|
||||
return `${dateFormatter.format(date)}, ${timeFormatter.format(date)}`
|
||||
return [dateFormatter.format(date), ...(includeTime ? [timeFormatter.format(date)] : [])].join(', ')
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// defined by the client (set using dangerouslySetInnerHTML)
|
||||
// (thus wee cannot rely on Tailwind for such content)
|
||||
|
||||
.status-content {
|
||||
.inner-html-content {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--wildebeest-vibrant-color-400);
|
||||
|
@ -17,28 +17,28 @@
|
|||
p {
|
||||
margin-bottom: theme('spacing.4');
|
||||
}
|
||||
}
|
||||
|
||||
.invisible {
|
||||
.invisible {
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
.ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ellipsis::after {
|
||||
.ellipsis::after {
|
||||
content: "...";
|
||||
}
|
||||
}
|
||||
|
||||
.status-link {
|
||||
.status-link {
|
||||
color: var(--wildebeest-vibrant-color-200);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { fetch } from 'undici'
|
||||
|
||||
describe('Account page', () => {
|
||||
it('should display the basic information of an account', async () => {
|
||||
const response = await fetch(`http://0.0.0.0:6868/@BethanyBlack`)
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.text()
|
||||
expect(body).toMatch(/<img [^<>]*alt="Avatar of Bethany Black"[^<>]*>/)
|
||||
expect(body).toMatch(/<h2 [^<>]*>Bethany Black<\/h2>/)
|
||||
expect(body).toMatch(
|
||||
/<div [^<>]*><dt [^<>]*>Joined<\/dt>[^<>]*<dd [^<>]*>[A-Z][a-z]{2} \d{1,2}, \d{4}<\/dd>[^<>]*<\/div>/
|
||||
)
|
||||
|
||||
expect(body).toMatch(
|
||||
/<div [^<>]*><dt [^<>]*>Joined<\/dt>[^<>]*<dd [^<>]*>[A-Z][a-z]{2} \d{1,2}, \d{4}<\/dd>[^<>]*<\/div>/
|
||||
)
|
||||
|
||||
const stats = [
|
||||
{ name: 'Posts', value: 1 },
|
||||
{ name: 'Posts', value: 1 },
|
||||
{ name: 'Following', value: 0 },
|
||||
{ name: 'Followers', value: 0 },
|
||||
]
|
||||
|
||||
stats.forEach(({ name, value }) => {
|
||||
const regex = new RegExp(`<span [^<>]*>${value}</span>[^<>]*<span [^<>]*>${name}</span>`)
|
||||
expect(body).toMatch(regex)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,14 +1,9 @@
|
|||
// https://docs.joinmastodon.org/methods/accounts/#get
|
||||
|
||||
import { cors } from 'wildebeest/backend/src/utils/cors'
|
||||
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { getActorById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { ContextData } from 'wildebeest/backend/src/types/context'
|
||||
import type { Env } from 'wildebeest/backend/src/types/env'
|
||||
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
|
||||
import type { Handle } from 'wildebeest/backend/src/utils/parse'
|
||||
import { queryAcct } from 'wildebeest/backend/src/webfinger/index'
|
||||
import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
|
||||
import { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
|
||||
|
||||
const headers = {
|
||||
...cors(),
|
||||
|
@ -21,41 +16,11 @@ export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request,
|
|||
}
|
||||
|
||||
export async function handleRequest(domain: string, id: string, db: D1Database): Promise<Response> {
|
||||
const handle = parseHandle(id)
|
||||
const account = await getAccount(domain, id, db)
|
||||
|
||||
if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
|
||||
// Retrieve the statuses from a local user
|
||||
return getLocalAccount(domain, db, handle)
|
||||
} else if (handle.domain !== null) {
|
||||
// Retrieve the statuses of a remote actor
|
||||
const acct = `${handle.localPart}@${handle.domain}`
|
||||
return getRemoteAccount(handle, acct)
|
||||
if (account) {
|
||||
return new Response(JSON.stringify(account), { headers })
|
||||
} else {
|
||||
return new Response('', { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteAccount(handle: Handle, acct: string): Promise<Response> {
|
||||
// TODO: using webfinger isn't the optimal implementation. We could cache
|
||||
// the object in D1 and directly query the remote API, indicated by the actor's
|
||||
// url field. For now, let's keep it simple.
|
||||
const actor = await queryAcct(handle.domain!, acct)
|
||||
if (actor === null) {
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const res = await loadExternalMastodonAccount(acct, actor, true)
|
||||
return new Response(JSON.stringify(res), { headers })
|
||||
}
|
||||
|
||||
async function getLocalAccount(domain: string, db: D1Database, handle: Handle): Promise<Response> {
|
||||
const actorId = actorURL(domain, handle.localPart)
|
||||
|
||||
const actor = await getActorById(db, actorId)
|
||||
if (actor === null) {
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
const res = await loadLocalMastodonAccount(db, actor)
|
||||
return new Response(JSON.stringify(res), { headers })
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue