Merge remote-tracking branch 'upstream/main' into fix-spread-and-rest-based-binding

pull/366/head
Jorge Caballero (DataDrivenMD) 2023-03-06 11:51:54 -08:00
commit 05c993151b
31 zmienionych plików z 228 dodań i 150 usunięć

Wyświetl plik

@ -6,16 +6,16 @@ export interface Client {
secret: string
name: string
redirect_uris: string
website: string
scopes: string
website?: string
}
export async function createClient(
db: Database,
name: string,
redirect_uris: string,
website: string,
scopes: string
scopes: string,
website?: string
): Promise<Client> {
const id = crypto.randomUUID()
@ -28,7 +28,10 @@ export async function createClient(
INSERT INTO clients (id, secret, name, redirect_uris, website, scopes)
VALUES (?, ?, ?, ?, ?, ?)
`
const { success, error } = await db.prepare(query).bind(id, secret, name, redirect_uris, website, scopes).run()
const { success, error } = await db
.prepare(query)
.bind(id, secret, name, redirect_uris, website === undefined ? null : website, scopes)
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}

Wyświetl plik

@ -0,0 +1,15 @@
import { type Database } from 'wildebeest/backend/src/database'
import { Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors'
export async function getAdmins(db: Database): Promise<Person[]> {
let rows: unknown[] = []
try {
const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=1')
const result = await stmt.all<unknown>()
rows = result.success ? (result.results as unknown[]) : []
} catch {
/* empty */
}
return rows.map(personFromRow)
}

Wyświetl plik

@ -0,0 +1,30 @@
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 './getAdmins'
import { isUserAuthenticated } from './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

@ -0,0 +1,22 @@
import * as access from 'wildebeest/backend/src/access'
export async function isUserAuthenticated(request: Request, jwt: string, accessAuthDomain: string, accessAud: string) {
if (!jwt) return false
try {
const validate = access.generateValidator({
jwt,
domain: accessAuthDomain,
aud: accessAud,
})
await validate(new Request(request.url))
} catch {
return false
}
const identity = await access.getIdentity({ jwt, domain: accessAuthDomain })
if (identity) {
return true
}
return false
}

Wyświetl plik

@ -36,6 +36,33 @@ describe('Mastodon APIs', () => {
assert.deepEqual(rest, {})
})
test('POST /apps registers client without website', async () => {
const db = await makeDB()
const vapidKeys = await generateVAPIDKeys()
const request = new Request('https://example.com', {
method: 'POST',
body: '{"redirect_uris":"mastodon://example.com/oauth","client_name":"Example mastodon client","scopes":"read write follow push"}',
headers: {
'content-type': 'application/json',
},
})
const res = await apps.handleRequest(db, request, vapidKeys)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { name, redirect_uri, client_id, client_secret, vapid_key, id, ...rest } = await res.json<
Record<string, string>
>()
assert.equal(name, 'Example mastodon client')
assert.equal(redirect_uri, 'mastodon://example.com/oauth')
assert.equal(id, '20')
assert.deepEqual(rest, {})
})
test('POST /apps returns 422 for malformed requests', async () => {
// client_name and redirect_uris are required according to https://docs.joinmastodon.org/methods/apps/#form-data-parameters
const db = await makeDB()

Wyświetl plik

@ -73,7 +73,7 @@ export async function createTestClient(
redirectUri: string = 'https://localhost',
scopes: string = 'read follow'
): Promise<Client> {
return createClient(db, 'test client', redirectUri, 'https://cloudflare.com', scopes)
return createClient(db, 'test client', redirectUri, scopes, 'https://cloudflare.com')
}
type TestQueue = Queue<any> & { messages: Array<any> }

Wyświetl plik

@ -1,7 +1,7 @@
import { component$ } from '@builder.io/qwik'
import { Link, useLocation } from '@builder.io/qwik-city'
import { WildebeestLogo } from '~/components/MastodonLogo'
import { accessLoader } from '~/routes/layout'
import { authLoader } from '~/routes/layout'
type LinkConfig = {
iconName: string
@ -11,7 +11,7 @@ type LinkConfig = {
}
export default component$(() => {
const accessData = accessLoader().value
const { isAuthorized, loginUrl } = authLoader().value
const location = useLocation()
const renderNavLink = ({ iconName, linkText, linkTarget, linkActiveRegex }: LinkConfig) => {
@ -55,15 +55,15 @@ export default component$(() => {
{renderNavLink(aboutLink)}
</div> */}
{!accessData.isAuthorized && (
{!isAuthorized && (
<a
class="w-full block mb-4 no-underline text-center bg-wildebeest-vibrant-600 hover:bg-wildebeest-vibrant-500 p-2 text-white text-uppercase border-wildebeest-vibrant-600 text-lg text-semi outline-none border rounded hover:border-wildebeest-vibrant-500 focus:border-wildebeest-vibrant-500"
href={accessData.loginUrl}
href={loginUrl}
>
Sign in
</a>
)}
{accessData.isAuthorized && (
{isAuthorized && (
<a class="text-semi no-underline" href="/settings/migration">
<i class="fa fa-gear mx-3 w-4" />
Preferences

Wyświetl plik

@ -8,7 +8,7 @@ import { getPersonByEmail } from 'wildebeest/backend/src/activitypub/actors'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import { buildRedirect } from 'wildebeest/functions/oauth/authorize'
import { getDatabase } from 'wildebeest/backend/src/database'
import { getJwtEmail } from '~/utils/getJwtEmail'
import { getJwtEmail } from 'wildebeest/backend/src/utils/auth/getJwtEmail'
export const clientLoader = loader$<Promise<Client>>(async ({ platform, query, html }) => {
const client_id = query.get('client_id') || ''
@ -21,7 +21,7 @@ export const clientLoader = loader$<Promise<Client>>(async ({ platform, query, h
throw html(500, getErrorHtml('An error occurred while trying to fetch the client data, please try again later'))
}
if (client === null) {
throw new Error('client not found')
throw html(500, getErrorHtml('client not found'))
}
return client
})

Wyświetl plik

@ -0,0 +1,11 @@
import { component$, Slot } from '@builder.io/qwik'
export { adminLoader } from '~/utils/adminLoader'
export default component$(() => {
return (
<>
<Slot />
</>
)
})

Wyświetl plik

@ -1,17 +1,4 @@
import { component$ } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
// import { checkAuth } from '~/utils/checkAuth'
export const loader = loader$(async ({ redirect }) => {
// Hiding this page for now
redirect(303, '/explore')
// const isAuthorized = await checkAuth(request, platform)
// if (!isAuthorized) {
// redirect(303, '/explore')
// }
})
export default component$(() => {
return (

Wyświetl plik

@ -17,7 +17,9 @@ export const action = action$(async (data, { request, platform }) => {
try {
const response = await handleRequestPost(
await getDatabase(platform),
new Request(request, { body: JSON.stringify(data) })
new Request(request, { body: JSON.stringify(data) }),
platform.ACCESS_AUTH_DOMAIN,
platform.ACCESS_AUD
)
success = response.ok
} catch (e: unknown) {

Wyświetl plik

@ -18,7 +18,9 @@ export const action = action$(async (data, { request, platform }) => {
try {
const response = await handleRequestPost(
await getDatabase(platform),
new Request(request, { body: JSON.stringify(data) })
new Request(request, { body: JSON.stringify(data) }),
platform.ACCESS_AUTH_DOMAIN,
platform.ACCESS_AUD
)
success = response.ok
} catch (e: unknown) {

Wyświetl plik

@ -1,14 +1,4 @@
import { component$, useStore, useSignal, $ } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
import { checkAuth } from '~/utils/checkAuth'
export const loader = loader$(async ({ request, platform, redirect }) => {
const isAuthorized = await checkAuth(request, platform)
if (!isAuthorized) {
redirect(303, '/explore')
}
})
export default component$(() => {
const ref = useSignal<Element>()

Wyświetl plik

@ -0,0 +1,11 @@
import { component$, Slot } from '@builder.io/qwik'
export { authLoader } from '~/utils/authLoader'
export default component$(() => {
return (
<>
<Slot />
</>
)
})

Wyświetl plik

@ -6,14 +6,14 @@ import { handleRequestGet as settingsHandleRequestGet } from 'wildebeest/functio
import { handleRequestGet as rulesHandleRequestGet } from 'wildebeest/functions/api/v1/instance/rules'
import { Accordion } from '~/components/Accordion/Accordion'
import { HtmlContent } from '~/components/HtmlContent/HtmlContent'
import { ServerSettingsData } from '~/routes/(admin)/settings/server-settings/layout'
import { ServerSettingsData } from '~/routes/(admin)/settings/(admin)/server-settings/layout'
import { Account } from '~/types'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { instanceLoader } from '../layout'
import { getAdmins } from 'wildebeest/functions/api/wb/settings/server/admins'
import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors'
import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { AccountCard } from '~/components/AccountCard/AccountCard'
import { getAdmins } from 'wildebeest/backend/src/utils/auth/getAdmins'
type AboutInfo = {
image: string
@ -84,9 +84,13 @@ export default component$(() => {
</h2>
<p data-testid="social-text" class="mb-6 text-wildebeest-500">
<span>
Decentralised social media powered by{' '}
<a href="https://joinmastodon.org" class="no-underline text-wildebeest-200 font-semibold" target="_blank">
Mastodon
Decentralized social network powered by{' '}
<a
href="https://github.com/cloudflare/wildebeest"
class="no-underline text-wildebeest-200 font-semibold"
target="_blank"
>
Wildebeest
</a>
</span>
</p>
@ -142,7 +146,7 @@ export const head: DocumentHead = ({ resolveValue, head }) => {
return getDocumentHead(
{
title: `About - ${instance.title}`,
description: `About page for the ${instance.title} Mastodon instance`,
description: `About page for ${instance.title}`,
og: {
type: 'website',
image: instance.thumbnail,

Wyświetl plik

@ -1,23 +1,28 @@
import { component$, Slot } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
import * as access from 'wildebeest/backend/src/access'
import { checkAuth } from '~/utils/checkAuth'
import { isUserAuthenticated } from 'wildebeest/backend/src/utils/auth/isUserAuthenticated'
type AccessLoaderData = {
loginUrl: string
type AuthLoaderData = {
loginUrl: URL
isAuthorized: boolean
}
export const accessLoader = loader$<Promise<AccessLoaderData>>(async ({ platform, request }) => {
const isAuthorized = await checkAuth(request, platform)
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
const UI_CLIENT_ID = '924801be-d211-495d-8cac-e73503413af8'
const params = new URLSearchParams({
redirect_uri: request.url,
response_type: 'code',
client_id: UI_CLIENT_ID,
scope: 'all',
})
const loginUrl = new URL('/oauth/authorize?' + params, 'https://' + platform.DOMAIN)
return {
isAuthorized,
loginUrl: access.generateLoginURL({
redirectURL: request.url,
domain: platform.ACCESS_AUTH_DOMAIN,
aud: platform.ACCESS_AUD,
}),
loginUrl,
}
})

Wyświetl plik

@ -0,0 +1,16 @@
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 { 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)
if (!isAdmin) {
return html(401, getErrorHtml('You need to be an admin to view this page'))
}
})

Wyświetl plik

@ -0,0 +1,19 @@
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
)
if (!isAuthenticated) {
return html(401, getErrorHtml("You're not authorized to view this page"))
}
})

Wyświetl plik

@ -1,29 +0,0 @@
import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler'
import * as access from 'wildebeest/backend/src/access'
type Env = {
ACCESS_AUTH_DOMAIN: string
ACCESS_AUD: string
}
export const checkAuth = async (request: RequestContext, platform: Env) => {
const jwt = request.headers.get('Cf-Access-Jwt-Assertion') || ''
if (!jwt) return false
try {
const validate = access.generateValidator({
jwt,
domain: platform.ACCESS_AUTH_DOMAIN,
aud: platform.ACCESS_AUD,
})
await validate(new Request(request.url))
} catch {
return false
}
const identity = await access.getIdentity({ jwt, domain: platform.ACCESS_AUTH_DOMAIN })
if (identity) {
return true
}
return false
}

Wyświetl plik

@ -1,17 +0,0 @@
import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors'
import { Database } from 'wildebeest/backend/src/database'
import { getAdmins } from 'wildebeest/functions/api/wb/settings/server/admins'
import { getJwtEmail } from './getJwtEmail'
export async function isUserAdmin(jwtCookie: string, database: Database): Promise<boolean> {
let email: string
try {
email = getJwtEmail(jwtCookie)
} catch {
return false
}
const admins = await getAdmins(database)
return admins.some((admin) => admin[emailSymbol] === email)
}

Wyświetl plik

@ -11,7 +11,7 @@ import { type Database, getDatabase } from 'wildebeest/backend/src/database'
type AppsPost = {
redirect_uris: string
website: string
website?: string
client_name: string
scopes: string
}
@ -42,9 +42,18 @@ export async function handleRequest(db: Database, request: Request, vapidKeys: J
} catch {
return errors.unprocessableEntity('redirect_uris must be a valid URI')
}
} else if (body.website) {
if (body.website.length > 2000) {
return errors.unprocessableEntity('website cannot exceed 2000 characters')
}
try {
new URL('', body.website)
} catch {
return errors.unprocessableEntity('website is invalid URI')
}
}
const client = await createClient(db, body.client_name, body.redirect_uris, body.website, body.scopes)
const client = await createClient(db, body.client_name, body.redirect_uris, body.scopes, body.website)
const vapidKey = VAPIDPublicKey(vapidKeys)
const res = {

Wyświetl plik

@ -1,26 +0,0 @@
import type { Env } from 'wildebeest/backend/src/types/env'
import type { ContextData } from 'wildebeest/backend/src/types/context'
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
import { Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors'
export const onRequestGet: PagesFunction<Env, any, ContextData> = async ({ env }) => {
return handleRequestGet(await getDatabase(env))
}
export async function handleRequestGet(db: Database) {
const admins = await getAdmins(db)
return new Response(JSON.stringify(admins), { status: 200 })
}
export async function getAdmins(db: Database): Promise<Person[]> {
let rows: unknown[] = []
try {
const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=TRUE')
const result = await stmt.all<unknown>()
rows = result.success ? (result.results as unknown[]) : []
} catch {
/* empty */
}
return rows.map(personFromRow)
}

Wyświetl plik

@ -3,10 +3,10 @@ import type { ContextData } from 'wildebeest/backend/src/types/context'
import * as errors from 'wildebeest/backend/src/errors'
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
import { parse } from 'cookie'
import { isUserAdmin } from 'wildebeest/frontend/src/utils/isUserAdmin'
import { isUserAdmin } from 'wildebeest/backend/src/utils/auth/isUserAdmin'
export const onRequestGet: PagesFunction<Env, any, ContextData> = async ({ env, request }) => {
return handleRequestPost(await getDatabase(env), request)
return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD)
}
export async function handleRequestGet(db: Database) {
@ -21,13 +21,13 @@ export async function handleRequestGet(db: Database) {
}
export const onRequestPost: PagesFunction<Env, any, ContextData> = async ({ env, request }) => {
return handleRequestPost(await getDatabase(env), request)
return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD)
}
export async function handleRequestPost(db: Database, request: Request) {
export async function handleRequestPost(db: Database, request: Request, accessAuthDomain: string, accessAud: string) {
const cookie = parse(request.headers.get('Cookie') || '')
const jwt = cookie['CF_Authorization']
const isAdmin = await isUserAdmin(jwt, db)
const isAdmin = await isUserAdmin(request, jwt, accessAuthDomain, accessAud, db)
if (!isAdmin) {
return errors.notAuthorized('Lacking authorization rights to edit server rules')
@ -56,10 +56,10 @@ export async function upsertRule(db: Database, rule: { id?: number; text: string
.run()
}
export async function handleRequestDelete(db: Database, request: Request) {
export async function handleRequestDelete(db: Database, request: Request, accessAuthDomain: string, accessAud: string) {
const cookie = parse(request.headers.get('Cookie') || '')
const jwt = cookie['CF_Authorization']
const isAdmin = await isUserAdmin(jwt, db)
const isAdmin = await isUserAdmin(request, jwt, accessAuthDomain, accessAud, db)
if (!isAdmin) {
return errors.notAuthorized('Lacking authorization rights to edit server rules')

Wyświetl plik

@ -3,11 +3,11 @@ import type { ContextData } from 'wildebeest/backend/src/types/context'
import * as errors from 'wildebeest/backend/src/errors'
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
import { parse } from 'cookie'
import { isUserAdmin } from 'wildebeest/frontend/src/utils/isUserAdmin'
import { ServerSettingsData } from 'wildebeest/frontend/src/routes/(admin)/settings/server-settings/layout'
import { ServerSettingsData } from 'wildebeest/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout'
import { isUserAdmin } from 'wildebeest/backend/src/utils/auth/isUserAdmin'
export const onRequestGet: PagesFunction<Env, any, ContextData> = async ({ env, request }) => {
return handleRequestPost(await getDatabase(env), request)
return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD)
}
export async function handleRequestGet(db: Database) {
@ -31,13 +31,13 @@ export async function handleRequestGet(db: Database) {
}
export const onRequestPost: PagesFunction<Env, any, ContextData> = async ({ env, request }) => {
return handleRequestPost(await getDatabase(env), request)
return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD)
}
export async function handleRequestPost(db: Database, request: Request) {
export async function handleRequestPost(db: Database, request: Request, accessAuthDomain: string, accessAud: string) {
const cookie = parse(request.headers.get('Cookie') || '')
const jwt = cookie['CF_Authorization']
const isAdmin = await isUserAdmin(jwt, db)
const isAdmin = await isUserAdmin(request, jwt, accessAuthDomain, accessAud, db)
if (!isAdmin) {
return errors.notAuthorized('Lacking authorization rights to edit server settings')

Wyświetl plik

@ -7,7 +7,7 @@ import { parse } from 'cookie'
import * as errors from 'wildebeest/backend/src/errors'
import * as access from 'wildebeest/backend/src/access'
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
import { getJwtEmail } from 'wildebeest/frontend/src/utils/getJwtEmail'
import { getJwtEmail } from 'wildebeest/backend/src/utils/auth/getJwtEmail'
export const onRequestPost: PagesFunction<Env, any, ContextData> = async ({ request, env }) => {
return handlePostRequest(request, await getDatabase(env), env.userKEK, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD)

Wyświetl plik

@ -8,6 +8,7 @@ import { getClientById } from 'wildebeest/backend/src/mastodon/client'
import * as access from 'wildebeest/backend/src/access'
import { getPersonByEmail } from 'wildebeest/backend/src/activitypub/actors'
import { type Database, getDatabase } from 'wildebeest/backend/src/database'
import { isUserAuthenticated } from 'wildebeest/backend/src/utils/auth/isUserAuthenticated'
// Extract the JWT token sent by Access (running before us).
const extractJWTFromRequest = (request: Request) => request.headers.get('Cf-Access-Jwt-Assertion') || ''
@ -80,18 +81,14 @@ export async function handleRequestPost(
}
const jwt = extractJWTFromRequest(request)
if (!jwt) {
const isAuthenticated = await isUserAuthenticated(request, jwt, accessDomain, accessAud)
if (!isAuthenticated) {
return new Response('', { status: 401 })
}
const validate = access.generateValidator({ jwt, domain: accessDomain, aud: accessAud })
await validate(request)
const identity = await access.getIdentity({ jwt, domain: accessDomain })
if (!identity) {
return new Response('', { status: 401 })
}
const isFirstLogin = (await getPersonByEmail(db, identity.email)) === null
const isFirstLogin = (await getPersonByEmail(db, identity!.email)) === null
return buildRedirect(db, request, isFirstLogin, jwt)
}