Merge remote-tracking branch 'upstream/main' into api/v1/instance

pull/363/head
Jorge Caballero (DataDrivenMD) 2023-03-09 10:40:47 -08:00
commit 1685c6a1f2
17 zmienionych plików z 130 dodań i 117 usunięć

Wyświetl plik

@ -0,0 +1,20 @@
import { setActorAlias } from 'wildebeest/backend/src/activitypub/actors'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { queryAcct } from 'wildebeest/backend/src/webfinger'
import { type Database } from 'wildebeest/backend/src/database'
export async function addAlias(db: Database, alias: string, connectedActor: Actor) {
const handle = parseHandle(alias)
const acct = `${handle.localPart}@${handle.domain}`
if (handle.domain === null) {
throw new Error("account migration within an instance isn't supported")
}
const actor = await queryAcct(handle.domain, db, acct)
if (actor === null) {
throw new Error('actor not found')
}
await setActorAlias(db, connectedActor.id, actor.id)
}

Wyświetl plik

@ -8,6 +8,7 @@ import { Buffer } from 'buffer'
const PERSON = 'Person'
const isTesting = typeof jest !== 'undefined'
export const emailSymbol = Symbol()
export const isAdminSymbol = Symbol()
export function actorURL(domain: string, id: string): URL {
return new URL(`/ap/users/${id}`, 'https://' + domain)
@ -23,6 +24,7 @@ export interface Actor extends APObject {
alsoKnownAs?: string
[emailSymbol]: string
[isAdminSymbol]: boolean
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
@ -223,7 +225,7 @@ export async function updateActorProperty(db: Database, actorId: URL, key: strin
export async function setActorAlias(db: Database, actorId: URL, alias: URL) {
const { success, error } = await db
.prepare(`UPDATE actors SET properties=json_set(properties, '$.alsoKnownAs', json_array(?)) WHERE id=?`)
.prepare(`UPDATE actors SET properties=json_set(properties, '$.alsoKnownAs', ${db.qb.jsonArray('?1')}) WHERE id=?2`)
.bind(alias.toString(), actorId.toString())
.run()
if (!success) {
@ -298,6 +300,7 @@ export function personFromRow(row: any): Person {
return {
// Hidden values
[emailSymbol]: row.email,
[isAdminSymbol]: row.is_admin === 1,
...properties,
name,

Wyświetl plik

@ -25,6 +25,10 @@ const qb: QueryBuilder = {
psqlOnly(): string {
return ''
},
jsonArray(r: string): string {
return `json_array(${r})`
},
}
export default function make({ DATABASE }: Pick<Env, 'DATABASE'>): Database {

Wyświetl plik

@ -33,6 +33,7 @@ export interface QueryBuilder {
epoch(): string
insertOrIgnore(q: string): string
psqlOnly(raw: string): string
jsonArray(r: string): string
}
export async function getDatabase(env: Pick<Env, 'DATABASE' | 'NEON_DATABASE_URL'>): Promise<Database> {

Wyświetl plik

@ -34,6 +34,10 @@ const qb: QueryBuilder = {
psqlOnly(q: string): string {
return q
},
jsonArray(r: string): string {
return `json_array_elements_text(${r})`
},
}
export default async function make(env: Pick<Env, 'NEON_DATABASE_URL'>): Promise<Database> {

Wyświetl plik

@ -1,30 +0,0 @@
import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors'
import { Database } from 'wildebeest/backend/src/database'
import { getJwtEmail } from 'wildebeest/backend/src/utils/auth/getJwtEmail'
import { getAdmins } from 'wildebeest/backend/src/utils/auth/getAdmins'
import { isUserAuthenticated } from 'wildebeest/backend/src/utils/auth/isUserAuthenticated'
export async function isUserAdmin(
request: Request,
jwt: string,
accessAuthDomain: string,
accessAud: string,
database: Database
): Promise<boolean> {
let email: string
try {
const authenticated = await isUserAuthenticated(request, jwt, accessAuthDomain, accessAud)
if (!authenticated) {
return false
}
email = getJwtEmail(jwt)
} catch {
return false
}
const admins = await getAdmins(database)
return admins.some((admin) => admin[emailSymbol] === email)
}

Wyświetl plik

@ -85,7 +85,7 @@ describe('Mastodon APIs', () => {
headers,
})
const res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
assert.equal(res.status, 403)
assert.equal(res.status, 422)
})
test('authorize redirects with code on success and show first login', async () => {

Wyświetl plik

@ -1,7 +1,7 @@
import { makeDB } from '../utils'
import { strict as assert } from 'node:assert/strict'
import { createPerson, getActorById } from 'wildebeest/backend/src/activitypub/actors'
import * as account_alias from 'wildebeest/functions/api/wb/settings/account/alias'
import * as alias from 'wildebeest/backend/src/accounts/alias'
const domain = 'cloudflare.com'
const userKEK = 'test_kek22'
@ -39,14 +39,7 @@ describe('Wildebeest', () => {
throw new Error('unexpected request to ' + input)
}
const alias = 'test@example.com'
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify({ alias }),
})
const res = await account_alias.handleRequestPost(db, req, actor)
assert.equal(res.status, 201)
await alias.addAlias(db, 'test@example.com', actor)
// Ensure the actor has the alias set
const newActor = await getActorById(db, actor.id)

Wyświetl plik

@ -4,13 +4,58 @@
* It's the entry point for cloudflare-pages when building for production.
*
* Learn more about the cloudflare integration here:
* - https://qwik.builder.io/qwikcity/adaptors/cloudflare-pages/
* - https://qwik.builder.io/integrations/deployments/cloudflare-pages/#cloudflare-pages-entry-middleware
*
*/
import { createQwikCity } from '@builder.io/qwik-city/middleware/cloudflare-pages'
import qwikCityPlan from '@qwik-city-plan'
import render from './entry.ssr'
import type { Env } from 'wildebeest/backend/src/types/env'
import type { ContextData } from 'wildebeest/backend/src/types/context'
import { parse } from 'cookie'
import * as access from 'wildebeest/backend/src/access'
import { getJwtEmail } from 'wildebeest/backend/src/utils/auth/getJwtEmail'
import * as errors from 'wildebeest/backend/src/errors'
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { getDatabase } from 'wildebeest/backend/src/database'
import type { Person } from 'wildebeest/backend/src/activitypub/actors'
const onRequest = createQwikCity({ render, qwikCityPlan })
const qwikHandler = createQwikCity({ render, qwikCityPlan })
export { onRequest }
type QwikContextData = {
connectedActor: Person | null
}
// eslint-disable-next-line
export const onRequest: PagesFunction<Env, any, ContextData> = async (ctx) => {
const cookie = parse(ctx.request.headers.get('Cookie') || '')
const jwt = cookie['CF_Authorization']
const data: QwikContextData = {
connectedActor: null,
}
if (jwt) {
const validate = access.generateValidator({
jwt,
domain: ctx.env.ACCESS_AUTH_DOMAIN,
aud: ctx.env.ACCESS_AUD,
})
await validate(ctx.request)
let email = ''
try {
email = getJwtEmail(jwt ?? '')
} catch (e) {
return errors.notAuthorized((e as Error)?.message)
}
const db = await getDatabase(ctx.env)
data.connectedActor = await actors.getPersonByEmail(db, email)
}
// eslint-disable-next-line
;(ctx.env as any).data = data
return qwikHandler(ctx)
}

Wyświetl plik

@ -1,7 +1,33 @@
import { component$, useStore, useSignal, $ } from '@builder.io/qwik'
import { getDatabase } from 'wildebeest/backend/src/database'
import { action$, Form, zod$, z } from '@builder.io/qwik-city'
import { addAlias } from 'wildebeest/backend/src/accounts/alias'
const zodSchema = zod$({
alias: z.string().min(1),
})
export const action = action$(async (data, { platform, json }) => {
const db = await getDatabase(platform)
const connectedActor = platform.data.connectedActor
if (connectedActor === null) {
throw json(500, { error: 'user not present in context' })
}
try {
await addAlias(db, data.alias, connectedActor)
} catch (e: unknown) {
const error = e as { stack: string; cause: string }
console.error(error.stack, error.cause)
throw json(500, { error: 'failed to add alias' })
}
return {
success: true,
}
}, zodSchema)
export default component$(() => {
const ref = useSignal<Element>()
const state = useStore({ alias: '' })
const toast = useSignal<'success' | 'failure' | null>(null)
@ -9,17 +35,10 @@ export default component$(() => {
state.alias = (event.target as HTMLInputElement).value
})
const handleSubmit = $(async () => {
const res = await fetch('/api/wb/settings/account/alias', { method: 'POST', body: JSON.stringify(state) })
if (res.status == 200) {
toast.value = 'success'
} else {
toast.value = 'failure'
}
})
const saveAction = action()
return (
<form ref={ref} class="login-form" preventdefault:submit onSubmit$={handleSubmit}>
<Form class="login-form" action={saveAction}>
<div class="max-w-4xl py-14 px-8">
<h2 class="text-2xl font-bold mb-6">Account Aliases</h2>
@ -92,6 +111,6 @@ export default component$(() => {
</tbody>
</table> */}
</div>
</form>
</Form>
)
})

Wyświetl plik

@ -16,7 +16,7 @@ export const statusesLoader = loader$<Promise<MastodonStatus[]>>(async ({ platfo
return JSON.parse(results) as MastodonStatus[]
} catch (e: unknown) {
const error = e as { stack: string; cause: string }
console.warn(error.stack, error.cause)
console.error(error.stack, error.cause)
throw html(500, getErrorHtml('The timeline is unavailable, please try again later'))
}
})

Wyświetl plik

@ -1,19 +1,17 @@
import { component$, Slot } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
import { isUserAuthenticated } from 'wildebeest/backend/src/utils/auth/isUserAuthenticated'
type AuthLoaderData = {
loginUrl: URL
isAuthorized: boolean
}
export const authLoader = loader$<Promise<AuthLoaderData>>(async ({ platform, request, cookie }) => {
const jwt = cookie.get('CF_Authorization')?.value ?? ''
const isAuthorized = await isUserAuthenticated(request, jwt, platform.ACCESS_AUTH_DOMAIN, platform.ACCESS_AUD)
// FIXME(sven): remove hardcoded value
export const authLoader = loader$<Promise<AuthLoaderData>>(async ({ platform }) => {
const isAuthorized = platform.data.connectedActor !== null
// defined in migrations/0010_add_ui_client.sql
const UI_CLIENT_ID = '924801be-d211-495d-8cac-e73503413af8'
const params = new URLSearchParams({
redirect_uri: request.url,
redirect_uri: '/',
response_type: 'code',
client_id: UI_CLIENT_ID,
scope: 'all',

Wyświetl plik

@ -1,14 +1,10 @@
import { loader$ } from '@builder.io/qwik-city'
import { parse } from 'cookie'
import { getDatabase } from 'wildebeest/backend/src/database'
import { isUserAdmin } from 'wildebeest/backend/src/utils/auth/isUserAdmin'
import { isAdminSymbol } from 'wildebeest/backend/src/activitypub/actors'
import { getErrorHtml } from './getErrorHtml/getErrorHtml'
export const adminLoader = loader$(async ({ request, platform, html }) => {
const database = await getDatabase(platform)
const cookie = parse(request.headers.get('Cookie') || '')
const jwtCookie = cookie.CF_Authorization ?? ''
const isAdmin = await isUserAdmin(request, jwtCookie, platform.ACCESS_AUTH_DOMAIN, platform.ACCESS_AUD, database)
export const adminLoader = loader$(async ({ platform, html }) => {
const isAuthorized = platform.data.connectedActor !== null
const isAdmin = isAuthorized && platform.data.connectedActor[isAdminSymbol]
if (!isAdmin) {
return html(401, getErrorHtml('You need to be an admin to view this page'))

Wyświetl plik

@ -1,17 +1,8 @@
import { loader$ } from '@builder.io/qwik-city'
import { parse } from 'cookie'
import { isUserAuthenticated } from 'wildebeest/backend/src/utils/auth/isUserAuthenticated'
import { getErrorHtml } from './getErrorHtml/getErrorHtml'
export const authLoader = loader$(async ({ request, platform, html }) => {
const cookie = parse(request.headers.get('Cookie') || '')
const jwtCookie = cookie.CF_Authorization ?? ''
const isAuthenticated = await isUserAuthenticated(
request,
jwtCookie,
platform.ACCESS_AUTH_DOMAIN,
platform.ACCESS_AUD
)
export const authLoader = loader$(async ({ platform, html }) => {
const isAuthenticated = platform.data.connectedActor !== null
if (!isAuthenticated) {
return html(401, getErrorHtml("You're not authorized to view this page"))

Wyświetl plik

@ -1,35 +0,0 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import { setActorAlias } from 'wildebeest/backend/src/activitypub/actors'
import type { ContextData } from 'wildebeest/backend/src/types/context'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import { queryAcct } from 'wildebeest/backend/src/webfinger'
import * as errors from 'wildebeest/backend/src/errors'
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
export const onRequestPost: PagesFunction<Env, any, ContextData> = async ({ env, request, data }) => {
return handleRequestPost(await getDatabase(env), request, data.connectedActor)
}
type AddAliasRequest = {
alias: string
}
export async function handleRequestPost(db: Database, request: Request, connectedActor: Actor): Promise<Response> {
const body = await request.json<AddAliasRequest>()
const handle = parseHandle(body.alias)
const acct = `${handle.localPart}@${handle.domain}`
if (handle.domain === null) {
console.warn("account migration within an instance isn't supported")
return new Response('', { status: 400 })
}
const actor = await queryAcct(handle.domain, db, acct)
if (actor === null) {
return errors.resourceNotFound('actor', acct)
}
await setActorAlias(db, connectedActor.id, actor.id)
return new Response('', { status: 201 })
}

Wyświetl plik

@ -50,7 +50,7 @@ export async function buildRedirect(
const redirect_uri = url.searchParams.get('redirect_uri')
if (client.redirect_uris !== redirect_uri) {
return new Response('', { status: 403 })
return errors.validationError('redirect_uri not allowed')
}
const code = `${client.id}.${jwt}`

Wyświetl plik

@ -0,0 +1,4 @@
-- Migration number: 0010 2023-03-08T09:40:30.734Z
INSERT INTO clients (id, secret, name, redirect_uris, scopes)
VALUES ('924801be-d211-495d-8cac-e73503413af8', hex(randomblob(42)), 'Wildebeest User Interface', '/', 'all');