kopia lustrzana https://github.com/cloudflare/wildebeest
Merge remote-tracking branch 'upstream/main' into api/v1/instance
commit
1685c6a1f2
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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'))
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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 })
|
||||
}
|
|
@ -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}`
|
||||
|
|
|
@ -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');
|
Ładowanie…
Reference in New Issue