diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts
index 7156827..25f64b1 100644
--- a/backend/src/activitypub/actors/index.ts
+++ b/backend/src/activitypub/actors/index.ts
@@ -150,7 +150,8 @@ export async function createPerson(
db: Database,
userKEK: string,
email: string,
- properties: PersonProperties = {}
+ properties: PersonProperties = {},
+ admin: boolean = false
): Promise {
const userKeyPair = await generateUserKey(userKEK)
@@ -198,12 +199,12 @@ export async function createPerson(
const row = await db
.prepare(
`
- INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties)
- VALUES(?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties, is_admin)
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
`
)
- .bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties))
+ .bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties), admin ? 1 : null)
.first()
return personFromRow(row)
diff --git a/frontend/mock-db/init.ts b/frontend/mock-db/init.ts
index dc97a27..a9cde78 100644
--- a/frontend/mock-db/init.ts
+++ b/frontend/mock-db/init.ts
@@ -74,12 +74,21 @@ async function getOrCreatePerson(
db: Database,
{ username, avatar, display_name }: Account
): Promise {
- const person = await getPersonByEmail(db, username)
+ const isAdmin = username === 'george'
+ const email = `${username}@test.email`
+ const person = await getPersonByEmail(db, email)
if (person) return person
- const newPerson = await createPerson(domain, db, 'test-kek', username, {
- icon: { url: avatar },
- name: display_name,
- })
+ const newPerson = await createPerson(
+ domain,
+ db,
+ 'test-kek',
+ email,
+ {
+ icon: { url: avatar },
+ name: display_name,
+ },
+ isAdmin
+ )
if (!newPerson) {
throw new Error('Could not create Actor ' + username)
}
diff --git a/frontend/src/components/Settings/TextArea.tsx b/frontend/src/components/Settings/TextArea.tsx
new file mode 100644
index 0000000..f83f302
--- /dev/null
+++ b/frontend/src/components/Settings/TextArea.tsx
@@ -0,0 +1,34 @@
+import { component$, useSignal } from '@builder.io/qwik'
+
+type Props = {
+ label: string
+ name?: string
+ description?: string
+ class?: string
+ invalid?: boolean
+ value?: string
+ required?: boolean
+}
+
+export const TextArea = component$(
+ ({ class: className, label, name, description, invalid, value, required }) => {
+ const inputId = useSignal(`${label.replace(/\s+/g, '_')}___${crypto.randomUUID()}`).value
+ return (
+
+
+ {label}
+ {!!required && * }
+
+ {!!description &&
{description}
}
+
+
+ )
+ }
+)
diff --git a/frontend/src/components/Settings/TextInput.tsx b/frontend/src/components/Settings/TextInput.tsx
new file mode 100644
index 0000000..1fb5df5
--- /dev/null
+++ b/frontend/src/components/Settings/TextInput.tsx
@@ -0,0 +1,36 @@
+import { component$, useSignal } from '@builder.io/qwik'
+
+type Props = {
+ label: string
+ name?: string
+ description?: string
+ class?: string
+ invalid?: boolean
+ value?: string
+ required?: boolean
+}
+
+export const TextInput = component$(
+ ({ class: className, label, name, description, invalid, value, required }) => {
+ const inputId = useSignal(`${label.replace(/\s+/g, '_')}___${crypto.randomUUID()}`).value
+ const includeDefaultMb = !/(^|\s)m[y,b]?-\S+(\s|$)/.test(className || '')
+ return (
+
+
+ {label}
+ {!!required && * }
+
+
+ {!!description &&
{description}
}
+
+ )
+ }
+)
diff --git a/frontend/src/routes/(admin)/oauth/authorize/index.tsx b/frontend/src/routes/(admin)/oauth/authorize/index.tsx
index e2fc9fb..027ce5d 100644
--- a/frontend/src/routes/(admin)/oauth/authorize/index.tsx
+++ b/frontend/src/routes/(admin)/oauth/authorize/index.tsx
@@ -1,5 +1,4 @@
import { component$ } from '@builder.io/qwik'
-import * as access from 'wildebeest/backend/src/access'
import type { Client } from 'wildebeest/backend/src/mastodon/client'
import { getClientById } from 'wildebeest/backend/src/mastodon/client'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
@@ -9,8 +8,9 @@ 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'
-export const clientLoader = loader$, { DATABASE: D1Database }>(async ({ platform, query, html }) => {
+export const clientLoader = loader$>(async ({ platform, query, html }) => {
const client_id = query.get('client_id') || ''
let client: Client | null = null
try {
@@ -26,50 +26,41 @@ export const clientLoader = loader$, { DATABASE: D1Database }>(a
return client
})
-export const userLoader = loader$<
- Promise<{ email: string; avatar: URL; name: string; url: URL }>,
- { DATABASE: D1Database; domain: string }
->(async ({ cookie, platform, html, request, redirect, text }) => {
- const jwt = cookie.get('CF_Authorization')
- if (jwt === null) {
- throw html(500, getErrorHtml('Missing Authorization'))
- }
- let payload: access.JWTPayload
- try {
- // TODO: eventually, verify the JWT with Access, however this
- // is not critical.
- payload = access.getPayload(jwt.value)
- } catch (e: unknown) {
- const error = e as { stack: string; cause: string }
- console.warn(error.stack, error.cause)
- throw html(500, getErrorHtml('Failed to validate Access JWT'))
- }
-
- if (!payload.email) {
- throw html(500, getErrorHtml("The Access JWT doesn't contain an email"))
- }
-
- const person = await getPersonByEmail(await getDatabase(platform), payload.email)
- if (person === null) {
- const isFirstLogin = true
- const res = await buildRedirect(await getDatabase(platform), request as Request, isFirstLogin, jwt.value)
- if (res.status === 302) {
- throw redirect(302, res.headers.get('location') || '')
- } else {
- throw text(res.status, await res.text())
+export const userLoader = loader$>(
+ async ({ cookie, platform, html, request, redirect, text }) => {
+ const jwt = cookie.get('CF_Authorization')
+ let email = ''
+ try {
+ email = getJwtEmail(jwt?.value ?? '')
+ } catch (e) {
+ throw html(500, getErrorHtml((e as Error)?.message))
}
+
+ const person = await getPersonByEmail(await getDatabase(platform), email)
+ if (person === null) {
+ const isFirstLogin = true
+ /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ -- jwt is defined otherwise getJwtEmail would have thrown
+ */
+ const res = await buildRedirect(await getDatabase(platform), request as Request, isFirstLogin, jwt!.value)
+ if (res.status === 302) {
+ throw redirect(302, res.headers.get('location') || '')
+ } else {
+ throw text(res.status, await res.text())
+ }
+ }
+
+ const name = person.name
+ const avatar = person.icon?.url
+ const url = person.url
+
+ if (!name || !avatar) {
+ throw html(500, getErrorHtml("The person associated with the Access JWT doesn't include a name or avatar"))
+ }
+
+ return { email, avatar, name, url }
}
-
- const name = person.name
- const avatar = person.icon?.url
- const url = person.url
-
- if (!name || !avatar) {
- throw html(500, getErrorHtml("The person associated with the Access JWT doesn't include a name or avatar"))
- }
-
- return { email: payload.email, avatar, name, url }
-})
+)
export default component$(() => {
const client = clientLoader().value
diff --git a/frontend/src/routes/(admin)/settings/server-settings/about/index.tsx b/frontend/src/routes/(admin)/settings/server-settings/about/index.tsx
new file mode 100644
index 0000000..1c7a7be
--- /dev/null
+++ b/frontend/src/routes/(admin)/settings/server-settings/about/index.tsx
@@ -0,0 +1,69 @@
+import { component$ } from '@builder.io/qwik'
+import { action$, Form, Link, z, zod$ } from '@builder.io/qwik-city'
+import { getDatabase } from 'wildebeest/backend/src/database'
+import { handleRequestPost } from 'wildebeest/functions/api/wb/settings/server/server'
+import { TextArea } from '~/components/Settings/TextArea'
+import { serverSettingsLoader } from '../layout'
+
+const zodSchema = zod$({
+ 'extended description': z.string().min(1),
+ 'privacy policy': z.string().optional(),
+})
+
+export type ServerAboutData = Awaited['_type']
+
+export const action = action$(async (data, { request, platform }) => {
+ let success = false
+ try {
+ const response = await handleRequestPost(
+ getDatabase(platform),
+ new Request(request, { body: JSON.stringify(data) })
+ )
+ success = response.ok
+ } catch (e: unknown) {
+ success = false
+ }
+
+ return {
+ success,
+ }
+}, zodSchema)
+
+export default component$(() => {
+ const existingSettings = serverSettingsLoader()
+ const saveAction = action()
+
+ return (
+
+ )
+})
diff --git a/frontend/src/routes/(admin)/settings/server-settings/branding/index.tsx b/frontend/src/routes/(admin)/settings/server-settings/branding/index.tsx
new file mode 100644
index 0000000..59c6059
--- /dev/null
+++ b/frontend/src/routes/(admin)/settings/server-settings/branding/index.tsx
@@ -0,0 +1,71 @@
+import { component$ } from '@builder.io/qwik'
+import { action$, Form, zod$, z } from '@builder.io/qwik-city'
+import { getDatabase } from 'wildebeest/backend/src/database'
+import { handleRequestPost } from 'wildebeest/functions/api/wb/settings/server/server'
+import { TextArea } from '~/components/Settings/TextArea'
+import { TextInput } from '~/components/Settings/TextInput'
+import { serverSettingsLoader } from '../layout'
+
+const zodSchema = zod$({
+ 'server name': z.string().min(1),
+ 'server description': z.string().min(1),
+})
+
+export type ServerBrandingData = Awaited['_type']
+
+export const action = action$(async (data, { request, platform }) => {
+ let success = false
+ try {
+ const response = await handleRequestPost(
+ await getDatabase(platform),
+ new Request(request, { body: JSON.stringify(data) })
+ )
+ success = response.ok
+ } catch (e: unknown) {
+ success = false
+ }
+
+ return {
+ success,
+ }
+}, zodSchema)
+
+export default component$(() => {
+ const existingSettings = serverSettingsLoader()
+ const saveAction = action()
+
+ return (
+
+
+ Your server's branding differentiates it from other servers in the network. This information may be displayed
+ across a variety of environments, such as Mastodon's web interface, native applications, in link previews on
+ other websites and within messaging apps, and so on. For this reason, it is best to keep this information clear,
+ short and concise.
+
+
+
+
+
+
+
+ Save Changes
+
+
+ )
+})
diff --git a/frontend/src/routes/(admin)/settings/server-settings/index.tsx b/frontend/src/routes/(admin)/settings/server-settings/index.tsx
new file mode 100644
index 0000000..3e86c26
--- /dev/null
+++ b/frontend/src/routes/(admin)/settings/server-settings/index.tsx
@@ -0,0 +1,8 @@
+import { component$ } from '@builder.io/qwik'
+import { loader$ } from '@builder.io/qwik-city'
+
+export const loader = loader$(({ redirect }) => {
+ redirect(303, 'server-settings/branding')
+})
+
+export default component$(() => <>>)
diff --git a/frontend/src/routes/(admin)/settings/server-settings/layout.tsx b/frontend/src/routes/(admin)/settings/server-settings/layout.tsx
new file mode 100644
index 0000000..89c1c71
--- /dev/null
+++ b/frontend/src/routes/(admin)/settings/server-settings/layout.tsx
@@ -0,0 +1,74 @@
+import { component$, Slot } from '@builder.io/qwik'
+import { Link, loader$, useLocation } from '@builder.io/qwik-city'
+import { getDatabase } from 'wildebeest/backend/src/database'
+import { handleRequestGet } from 'wildebeest/functions/api/wb/settings/server/server'
+import { ServerAboutData } from './about'
+import { ServerBrandingData } from './branding'
+
+export type ServerSettingsData = ServerBrandingData & ServerAboutData
+
+export const serverSettingsLoader = loader$>>(async ({ platform }) => {
+ const database = await getDatabase(platform)
+
+ const settingsResp = await handleRequestGet(database)
+ let settingsData: Partial = {}
+ try {
+ settingsData = await settingsResp.json()
+ } catch {
+ settingsData = {}
+ }
+
+ return JSON.parse(JSON.stringify(settingsData))
+})
+
+export default component$(() => {
+ const sectionLinks = [
+ {
+ text: 'Branding',
+ faIcon: 'fa-pen',
+ path: 'branding',
+ },
+ {
+ text: 'About',
+ faIcon: 'fa-file-lines',
+ path: 'about',
+ },
+ {
+ text: 'Rules',
+ faIcon: 'fa-pen-ruler',
+ path: 'rules',
+ },
+ ] as const
+
+ const currentPath = useLocation().url.pathname.replace(/\/$/, '')
+
+ return (
+
+
Server Settings
+
+
+ {sectionLinks.map(({ text, faIcon, path }) => {
+ const isActive = currentPath.endsWith(path)
+ return (
+
+
+ {text}
+
+ )
+ })}
+
+
+
+
+ )
+})
diff --git a/frontend/src/routes/(admin)/settings/server-settings/rules/edit/[id]/index.tsx b/frontend/src/routes/(admin)/settings/server-settings/rules/edit/[id]/index.tsx
new file mode 100644
index 0000000..a6b3f60
--- /dev/null
+++ b/frontend/src/routes/(admin)/settings/server-settings/rules/edit/[id]/index.tsx
@@ -0,0 +1,96 @@
+import { component$ } from '@builder.io/qwik'
+import { action$, Form, loader$, useNavigate, z, zod$ } from '@builder.io/qwik-city'
+import { getDatabase } from 'wildebeest/backend/src/database'
+import { handleRequestGet } from 'wildebeest/functions/api/v1/instance/rules'
+import { upsertRule } from 'wildebeest/functions/api/wb/settings/server/rules'
+import { TextArea } from '~/components/Settings/TextArea'
+import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
+
+export type ServerSettingsData = { rules: string[] }
+
+export const editAction = action$(
+ async (data, { platform }) => {
+ let success = false
+ try {
+ const result = await upsertRule(await getDatabase(platform), {
+ id: +data.id,
+ text: data.text,
+ })
+ success = result.success
+ } catch (e: unknown) {
+ success = false
+ }
+
+ return {
+ success,
+ }
+ },
+ zod$({
+ id: z.string().min(1),
+ text: z.string().min(1),
+ })
+)
+
+export const ruleLoader = loader$>(async ({ params, platform, html }) => {
+ const database = await getDatabase(platform)
+
+ const settingsResp = await handleRequestGet(database)
+ let rules: { id: number; text: string }[] = []
+ try {
+ rules = await settingsResp.json()
+ } catch {
+ rules = []
+ }
+
+ const rule: { id: number; text: string } | undefined = rules.find((r) => r.id === +params['id'])
+
+ if (!rule) {
+ throw html(404, getErrorHtml('The selected rule could not be found'))
+ }
+
+ return JSON.parse(JSON.stringify(rule))
+})
+
+export default component$(() => {
+ const rule = ruleLoader()
+ const editActionObj = editAction()
+
+ const nav = useNavigate()
+
+ if (editActionObj.value?.success) {
+ nav('/settings/server-settings/rules')
+ }
+
+ return (
+ <>
+
+
+ While most claim to have read and agree to the terms of service, usually people do not read through until
+ after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat
+ bullet point list. Try to keep individual rules short and simple, but try not to split them up into many
+ separate items either.
+
+
+
+
+
+
+
+
+
+ Save Changes
+
+
+ >
+ )
+})
diff --git a/frontend/src/routes/(admin)/settings/server-settings/rules/index.tsx b/frontend/src/routes/(admin)/settings/server-settings/rules/index.tsx
new file mode 100644
index 0000000..eac772c
--- /dev/null
+++ b/frontend/src/routes/(admin)/settings/server-settings/rules/index.tsx
@@ -0,0 +1,122 @@
+import { component$ } from '@builder.io/qwik'
+import { action$, Form, Link, loader$, z, zod$ } from '@builder.io/qwik-city'
+import { getDatabase } from 'wildebeest/backend/src/database'
+import { handleRequestGet } from 'wildebeest/functions/api/v1/instance/rules'
+import { deleteRule, upsertRule } from 'wildebeest/functions/api/wb/settings/server/rules'
+import { TextArea } from '~/components/Settings/TextArea'
+
+export type ServerSettingsData = { rules: string[] }
+
+export const addAction = action$(
+ async (data, { platform }) => {
+ let success = false
+ try {
+ const result = await upsertRule(await getDatabase(platform), data.text)
+ success = result.success
+ } catch (e: unknown) {
+ success = false
+ }
+
+ return {
+ success,
+ }
+ },
+ zod$({
+ text: z.string().min(1),
+ })
+)
+
+export const deleteAction = action$(
+ async (data, { platform }) => {
+ let success = false
+
+ try {
+ const result = await deleteRule(await getDatabase(platform), data.id)
+ success = result.success
+ } catch (e: unknown) {
+ success = false
+ }
+
+ return {
+ success,
+ }
+ },
+ zod$({
+ id: z.number(),
+ })
+)
+
+export const rulesLoader = loader$>(async ({ platform }) => {
+ const database = await getDatabase(platform)
+
+ const settingsResp = await handleRequestGet(database)
+ let rules: { id: number; text: string }[] = []
+ try {
+ rules = await settingsResp.json()
+ } catch {
+ rules = []
+ }
+
+ return JSON.parse(JSON.stringify(rules))
+})
+
+export default component$(() => {
+ const rules = rulesLoader()
+ const addActionObj = addAction()
+ const deleteActionObj = deleteAction()
+
+ return (
+ <>
+
+
+ While most claim to have read and agree to the terms of service, usually people do not read through until
+ after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat
+ bullet point list. Try to keep individual rules short and simple, but try not to split them up into many
+ separate items either.
+
+
+
+
+
+
+
+ Add Rule
+
+
+
+ {rules.value.map(({ id, text }, idx) => {
+ const ruleId = idx + 1
+ const ruleBtnText = `${ruleId}. ${text.slice(0, 27)}${text.length > 27 ? '...' : ''}`
+ return (
+
+
+ {ruleBtnText}
+
+
+ {text}
+ {
+ if (confirm('Are you sure?')) {
+ deleteActionObj.run({ id })
+ }
+ }}
+ >
+ Delete
+
+
+
+ )
+ })}
+
+ >
+ )
+})
diff --git a/frontend/src/routes/(frontend)/[accountId]/layout.tsx b/frontend/src/routes/(frontend)/[accountId]/layout.tsx
index 18a8b80..7d964ea 100644
--- a/frontend/src/routes/(frontend)/[accountId]/layout.tsx
+++ b/frontend/src/routes/(frontend)/[accountId]/layout.tsx
@@ -15,8 +15,7 @@ import { getDatabase } from 'wildebeest/backend/src/database'
import { Person } from 'wildebeest/backend/src/activitypub/actors'
export const accountPageLoader = loader$<
- Promise<{ account: MastodonAccount; accountHandle: string; isValidStatus: boolean }>,
- { DATABASE: D1Database }
+ Promise<{ account: MastodonAccount; accountHandle: string; isValidStatus: boolean }>
>(async ({ platform, params, request, html }) => {
let isValidStatus = false
let account: MastodonAccount | null = null
diff --git a/frontend/src/routes/(frontend)/about/index.tsx b/frontend/src/routes/(frontend)/about/index.tsx
index d24a8b9..0ec0fa6 100644
--- a/frontend/src/routes/(frontend)/about/index.tsx
+++ b/frontend/src/routes/(frontend)/about/index.tsx
@@ -1,67 +1,76 @@
import { component$ } from '@builder.io/qwik'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
+import { getDatabase } from 'wildebeest/backend/src/database'
import { getDomain } from 'wildebeest/backend/src/utils/getDomain'
+import { handleRequestGet as settingsHandleRequestGet } from 'wildebeest/functions/api/wb/settings/server/server'
+import { handleRequestGet as rulesHandleRequestGet } from 'wildebeest/functions/api/v1/instance/rules'
import { Accordion } from '~/components/Accordion/Accordion'
-import { AccountCard } from '~/components/AccountCard/AccountCard'
+// import { AccountCard } from '~/components/AccountCard/AccountCard'
import { HtmlContent } from '~/components/HtmlContent/HtmlContent'
-import { george } from '~/dummyData/accounts'
+import { ServerSettingsData } from '~/routes/(admin)/settings/server-settings/layout'
import { Account } from '~/types'
import { getDocumentHead } from '~/utils/getDocumentHead'
-import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
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 { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
type AboutInfo = {
image: string
domain: string
- contact: {
- account: Account
- email: string
- }
- rules: { id: string; text: string }[]
+ admin: { account: Account | null; email: string }
+ rules: { id: number; text: string }[]
extended_description: {
- updated_at: string
content: string
}
}
-export const aboutInfoLoader = loader$>(async ({ resolveValue, request, html }) => {
- // TODO: properly implement loader and remove the following 404 throw
+export const aboutInfoLoader = loader$>(async ({ resolveValue, request, platform, html }) => {
throw html(404, getNotFoundHtml())
+ // TODO: fetching the instance for the thumbnail, but that should be part of the settings
const instance = await resolveValue(instanceLoader)
+
+ const database = await getDatabase(platform)
+
+ const brandingDataResp = await settingsHandleRequestGet(database)
+ let brandingData: ServerSettingsData | null
+ try {
+ brandingData = await brandingDataResp.json()
+ } catch {
+ brandingData = null
+ }
+
+ const rulesResp = await rulesHandleRequestGet(database)
+ let rules: { id: number; text: string }[] = []
+ try {
+ rules = await rulesResp.json()
+ } catch {
+ rules = []
+ }
+
+ const admins = await getAdmins(database)
+ let adminAccount: Account | null = null
+
+ const adminPerson = admins.find((admin) => admin[emailSymbol] === platform.ADMIN_EMAIL)
+
+ if (adminPerson) {
+ try {
+ adminAccount = (await loadLocalMastodonAccount(database, adminPerson)) as Account
+ } catch {
+ /* empty */
+ }
+ }
+
return {
image: instance.thumbnail,
domain: getDomain(request.url),
- contact: {
- account: george,
- email: 'test@test.com',
- },
- rules: [
- {
- id: '1',
- text: 'Sexually explicit or violent media must be marked as sensitive when posting',
- },
- {
- id: '2',
- text: 'No racism, sexism, homophobia, transphobia, xenophobia, or casteism',
- },
- {
- id: '3',
- text: 'No incitement of violence or promotion of violent ideologies',
- },
- {
- id: '4',
- text: 'No harassment, dogpiling or doxxing of other users',
- },
- {
- id: '7',
- text: 'Do not share intentionally false or misleading information',
- },
- ],
+ admin: { account: JSON.parse(JSON.stringify(adminAccount)), email: platform.ADMIN_EMAIL },
+ rules: JSON.parse(JSON.stringify(rules.sort(({ id: idA }, { id: idB }) => idA - idB))),
extended_description: {
- updated_at: '2023-01-19T14:55:44Z',
- content:
- 'Please mind that the staff@mastodon.social e-mail is for inquiries related to the operation of the mastodon.social server specifically. If your account is on another server, we will not be able to assist you . For inquiries not related specifically to the operation of this server, such as press inquiries about Mastodon gGmbH, please contact press@joinmastodon.org . Additional addresses:
\n\n\n\nFunding \n\nThis server is crowdfunded by Patreon donations . For a list of sponsors, see joinmastodon.org .
\n\nReporting and moderation \n\nWhen reporting accounts, please make sure to include at least a few posts that show rule-breaking behaviour, when applicable. If there is any additional context that might help make a decision, please also include it in the comment. This is especially important when the content is in a language nobody on the moderation team speaks.
\n\nWe usually handle reports within 24 hours. Please mind that you are not notified when a report you have made has led to a punitive action, and that not all punitive actions are externally visible. For first time offenses, we may opt to delete offending content, escalating to harsher measures on repeat offenses.
\n\nWe have a team of paid moderators. If you would like to become a moderator, get in touch with us through the e-mail address above.
\n\nImpressum \n\nMastodon gGmbH \nMühlenstraße 8a \n14167 Berlin \nGermany
\n\nE-Mail-Adresse: hello@joinmastodon.org
\n\nVertretungsberechtigt: Eugen Rochko (Geschäftsführer)
\n\nUmsatzsteuer Identifikationsnummer (USt-ID): DE344258260
\n\nHandelsregister \nGeführt bei: Amtsgericht Charlottenburg \nNummer: HRB 230086 B
\n',
+ content: brandingData?.['extended description'] ?? '',
},
}
})
@@ -86,14 +95,19 @@ export default component$(() => {
-
-
-
+
+ {!!aboutInfo.admin.account && (
+
+ )}
+
Contact:
- {aboutInfo.contact.email}
+ {aboutInfo.admin.email}
diff --git a/frontend/src/routes/(frontend)/layout.tsx b/frontend/src/routes/(frontend)/layout.tsx
index 6b29dd9..4458ba3 100644
--- a/frontend/src/routes/(frontend)/layout.tsx
+++ b/frontend/src/routes/(frontend)/layout.tsx
@@ -11,10 +11,7 @@ import { InstanceConfigContext } from '~/utils/instanceConfig'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
-export const instanceLoader = loader$<
- Promise
,
- { DATABASE: D1Database; INSTANCE_TITLE: string; INSTANCE_DESCR: string; ADMIN_EMAIL: string }
->(async ({ platform, html }) => {
+export const instanceLoader = loader$>(async ({ platform, html }) => {
const env = {
INSTANCE_DESCR: platform.INSTANCE_DESCR,
INSTANCE_TITLE: platform.INSTANCE_TITLE,
diff --git a/frontend/src/utils/getJwtEmail.ts b/frontend/src/utils/getJwtEmail.ts
new file mode 100644
index 0000000..cdf2feb
--- /dev/null
+++ b/frontend/src/utils/getJwtEmail.ts
@@ -0,0 +1,23 @@
+import * as access from 'wildebeest/backend/src/access'
+
+export function getJwtEmail(jwtCookie: string) {
+ let payload: access.JWTPayload
+ if (!jwtCookie) {
+ throw new Error('Missing Authorization')
+ }
+ try {
+ // TODO: eventually, verify the JWT with Access, however this
+ // is not critical.
+ payload = access.getPayload(jwtCookie)
+ } catch (e: unknown) {
+ const error = e as { stack: string; cause: string }
+ console.warn(error.stack, error.cause)
+ throw new Error('Failed to validate Access JWT')
+ }
+
+ if (!payload.email) {
+ throw new Error("The Access JWT doesn't contain an email")
+ }
+
+ return payload.email
+}
diff --git a/frontend/src/utils/isUserAdmin.ts b/frontend/src/utils/isUserAdmin.ts
new file mode 100644
index 0000000..a247d96
--- /dev/null
+++ b/frontend/src/utils/isUserAdmin.ts
@@ -0,0 +1,17 @@
+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 {
+ let email: string
+ try {
+ email = getJwtEmail(jwtCookie)
+ } catch {
+ return false
+ }
+
+ const admins = await getAdmins(database)
+
+ return admins.some((admin) => admin[emailSymbol] === email)
+}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index e7405e0..e4fca22 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1328,7 +1328,7 @@ chalk@^2.0.0:
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
-chalk@^4.0.0, chalk@^4.1.0:
+chalk@^4.0.0:
version "4.1.2"
resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -1456,21 +1456,6 @@ concat-map@0.0.1:
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
-concurrently@^7.6.0:
- version "7.6.0"
- resolved "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz"
- integrity sha512-BKtRgvcJGeZ4XttiDiNcFiRlxoAeZOseqUvyYRUp/Vtd+9p1ULmeoSqGsDA+2ivdeDFpqrJvGvmI+StKfKl5hw==
- dependencies:
- chalk "^4.1.0"
- date-fns "^2.29.1"
- lodash "^4.17.21"
- rxjs "^7.0.0"
- shell-quote "^1.7.3"
- spawn-command "^0.0.2-1"
- supports-color "^8.1.0"
- tree-kill "^1.2.2"
- yargs "^17.3.1"
-
convert-source-map@^1.6.0, convert-source-map@^1.7.0:
version "1.9.0"
resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz"
@@ -1500,11 +1485,6 @@ data-uri-to-buffer@^4.0.0:
resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz"
integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==
-date-fns@^2.29.1:
- version "2.29.3"
- resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz"
- integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
-
debug@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
@@ -2826,11 +2806,6 @@ lodash.merge@^4.6.2:
resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
-lodash@^4.17.21:
- version "4.17.21"
- resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
- integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
-
longest-streak@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz"
@@ -3810,13 +3785,6 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
-rxjs@^7.0.0:
- version "7.8.0"
- resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz"
- integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
- dependencies:
- tslib "^2.1.0"
-
sade@^1.7.3:
version "1.8.1"
resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz"
@@ -3867,11 +3835,6 @@ shebang-regex@^3.0.0:
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
-shell-quote@^1.7.3:
- version "1.7.4"
- resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.4.tgz"
- integrity sha512-8o/QEhSSRb1a5i7TFR0iM4G16Z0vYB2OQVs4G3aAFXjn3T6yEx8AZxy1PgDF7I00LZHYA3WxaSYIf5e5sAX8Rw==
-
signal-exit@^3.0.3, signal-exit@^3.0.7:
version "3.0.7"
resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz"
@@ -3922,11 +3885,6 @@ space-separated-tokens@^2.0.0:
resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz"
integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
-spawn-command@^0.0.2-1:
- version "0.0.2-1"
- resolved "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz"
- integrity sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==
-
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
@@ -4029,7 +3987,7 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
-supports-color@^8.0.0, supports-color@^8.1.0:
+supports-color@^8.0.0:
version "8.1.1"
resolved "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz"
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
@@ -4122,11 +4080,6 @@ touch@^3.1.0:
dependencies:
nopt "~1.0.10"
-tree-kill@^1.2.2:
- version "1.2.2"
- resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz"
- integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==
-
trim-lines@^3.0.0:
version "3.0.1"
resolved "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz"
@@ -4170,11 +4123,6 @@ tslib@^1.8.1, tslib@^1.9.3:
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
-tslib@^2.1.0:
- version "2.4.1"
- resolved "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz"
- integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
-
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz"
diff --git a/functions/api/v1/instance/rules.ts b/functions/api/v1/instance/rules.ts
new file mode 100644
index 0000000..b2e7fc6
--- /dev/null
+++ b/functions/api/v1/instance/rules.ts
@@ -0,0 +1,18 @@
+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'
+
+export const onRequestGet: PagesFunction = async ({ env }) => {
+ return handleRequestGet(await getDatabase(env))
+}
+
+export async function handleRequestGet(db: Database) {
+ const query = `SELECT * from server_rules;`
+ const result = await db.prepare(query).all<{ id: string; text: string }>()
+
+ if (!result.success) {
+ return new Response('SQL error: ' + result.error, { status: 500 })
+ }
+
+ return new Response(JSON.stringify(result.results ?? []), { status: 200 })
+}
diff --git a/functions/api/wb/settings/server/admins.ts b/functions/api/wb/settings/server/admins.ts
new file mode 100644
index 0000000..51a7dec
--- /dev/null
+++ b/functions/api/wb/settings/server/admins.ts
@@ -0,0 +1,26 @@
+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 = 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 {
+ let rows: unknown[] = []
+ try {
+ const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=TRUE')
+ const result = await stmt.all()
+ rows = result.success ? (result.results as unknown[]) : []
+ } catch {
+ /* empty */
+ }
+
+ return rows.map(personFromRow)
+}
diff --git a/functions/api/wb/settings/server/rules.ts b/functions/api/wb/settings/server/rules.ts
new file mode 100644
index 0000000..359c70b
--- /dev/null
+++ b/functions/api/wb/settings/server/rules.ts
@@ -0,0 +1,65 @@
+import type { Env } from 'wildebeest/backend/src/types/env'
+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'
+
+export const onRequestPost: PagesFunction = async ({ env, request }) => {
+ return handleRequestPost(await getDatabase(env), request)
+}
+
+export async function handleRequestPost(db: Database, request: Request) {
+ const cookie = parse(request.headers.get('Cookie') || '')
+ const jwt = cookie['CF_Authorization']
+ const isAdmin = await isUserAdmin(jwt, db)
+
+ if (!isAdmin) {
+ return errors.notAuthorized('Lacking authorization rights to edit server rules')
+ }
+
+ const rule = await request.json<{ id?: number; text: string }>()
+ const result = await upsertRule(db, rule)
+
+ if (!result.success) {
+ return new Response('SQL error: ' + result.error, { status: 500 })
+ }
+
+ return new Response('', { status: 200 })
+}
+
+export async function upsertRule(db: Database, rule: { id?: number; text: string } | string) {
+ const id = typeof rule === 'string' ? null : rule.id ?? null
+ const text = typeof rule === 'string' ? rule : rule.text
+ return await db
+ .prepare(
+ `INSERT INTO server_rules (id, text)
+ VALUES (?, ?)
+ ON CONFLICT(id) DO UPDATE SET text=excluded.text;`
+ )
+ .bind(id, text)
+ .run()
+}
+
+export async function handleRequestDelete(db: Database, request: Request) {
+ const cookie = parse(request.headers.get('Cookie') || '')
+ const jwt = cookie['CF_Authorization']
+ const isAdmin = await isUserAdmin(jwt, db)
+
+ if (!isAdmin) {
+ return errors.notAuthorized('Lacking authorization rights to edit server rules')
+ }
+
+ const rule = await request.json<{ id: number }>()
+ const result = await deleteRule(db, rule.id)
+
+ if (!result.success) {
+ return new Response('SQL error: ' + result.error, { status: 500 })
+ }
+
+ return new Response('', { status: 200 })
+}
+
+export async function deleteRule(db: Database, ruleId: number) {
+ return await db.prepare('DELETE FROM server_rules WHERE id=?').bind(ruleId).run()
+}
diff --git a/functions/api/wb/settings/server/server.ts b/functions/api/wb/settings/server/server.ts
new file mode 100644
index 0000000..3cbd7d2
--- /dev/null
+++ b/functions/api/wb/settings/server/server.ts
@@ -0,0 +1,64 @@
+import type { Env } from 'wildebeest/backend/src/types/env'
+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 { ServerBrandingData } from 'wildebeest/frontend/src/routes/(admin)/settings/server-settings/branding'
+import { parse } from 'cookie'
+import { isUserAdmin } from 'wildebeest/frontend/src/utils/isUserAdmin'
+
+export const onRequestGet: PagesFunction = async ({ env, request }) => {
+ return handleRequestPost(await getDatabase(env), request)
+}
+
+export async function handleRequestGet(db: Database) {
+ const query = `SELECT * from server_settings`
+ const result = await db.prepare(query).all<{ setting_name: string; setting_value: string }>()
+
+ const data = (result.results ?? []).reduce(
+ (settings, { setting_name, setting_value }) => ({
+ ...settings,
+ [setting_name]: setting_value,
+ }),
+ {} as Object
+ )
+
+ if (!result.success) {
+ return new Response('SQL error: ' + result.error, { status: 500 })
+ }
+
+ return new Response(JSON.stringify(data), { status: 200 })
+}
+
+export const onRequestPost: PagesFunction = async ({ env, request }) => {
+ return handleRequestPost(await getDatabase(env), request)
+}
+
+export async function handleRequestPost(db: Database, request: Request) {
+ const cookie = parse(request.headers.get('Cookie') || '')
+ const jwt = cookie['CF_Authorization']
+ const isAdmin = await isUserAdmin(jwt, db)
+
+ if (!isAdmin) {
+ return errors.notAuthorized('Lacking authorization rights to edit server settings')
+ }
+
+ const data = await request.json()
+
+ const settingsEntries = Object.entries(data)
+
+ const query = `
+ INSERT INTO server_settings (setting_name, setting_value)
+ VALUES ${settingsEntries.map(() => `(?, ?)`).join(', ')}
+ ON CONFLICT(setting_name) DO UPDATE SET setting_value=excluded.setting_value
+ `
+ const result = await db
+ .prepare(query)
+ .bind(...settingsEntries.flat())
+ .run()
+
+ if (!result.success) {
+ return new Response('SQL error: ' + result.error, { status: 500 })
+ }
+
+ return new Response('', { status: 200 })
+}
diff --git a/functions/first-login.ts b/functions/first-login.ts
index c175aec..9a69ade 100644
--- a/functions/first-login.ts
+++ b/functions/first-login.ts
@@ -7,6 +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'
export const onRequestPost: PagesFunction = async ({ request, env }) => {
return handlePostRequest(request, await getDatabase(env), env.userKEK, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD)
@@ -21,23 +22,19 @@ export async function handlePostRequest(
): Promise {
const url = new URL(request.url)
const cookie = parse(request.headers.get('Cookie') || '')
-
+ let email = ''
const jwt = cookie['CF_Authorization']
- if (!jwt) {
- return errors.notAuthorized('missing CF_Authorization')
+ try {
+ email = getJwtEmail(jwt ?? '')
+ } catch (e) {
+ return errors.notAuthorized((e as Error)?.message)
}
- const payload = access.getPayload(jwt)
- if (!payload.email) {
- return errors.notAuthorized('missing email')
- }
-
- const validatate = access.generateValidator({
+ await access.generateValidator({
jwt,
domain: accessDomain,
aud: accessAud,
- })
- await validatate(request)
+ })(request)
const domain = url.hostname
@@ -52,7 +49,7 @@ export async function handlePostRequest(
properties.name = formData.get('name') || ''
}
- await createPerson(domain, db, userKEK, payload.email, properties)
+ await createPerson(domain, db, userKEK, email, properties)
if (!url.searchParams.has('redirect_uri')) {
return new Response('', { status: 400 })
diff --git a/migrations/0008_add_server-settings.sql b/migrations/0008_add_server-settings.sql
new file mode 100644
index 0000000..5438347
--- /dev/null
+++ b/migrations/0008_add_server-settings.sql
@@ -0,0 +1,11 @@
+-- Migration number: 0003 2023-02-24T15:03:27.478Z
+
+CREATE TABLE IF NOT EXISTS server_settings (
+ setting_name TEXT UNIQUE NOT NULL,
+ setting_value TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS server_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ text TEXT NOT NULL
+);
diff --git a/package.json b/package.json
index ce8b5d9..90d7a22 100644
--- a/package.json
+++ b/package.json
@@ -37,8 +37,8 @@
"pages": "NO_D1_WARNING=true wrangler pages",
"database:migrate": "yarn d1 migrations apply DATABASE",
"database:create-mock": "rm -f .wrangler/state/d1/DATABASE.sqlite3 && CI=true yarn database:migrate --local && node ./frontend/mock-db/run.mjs",
- "dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' --live-reload",
- "ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' --compatibility-date=2022-12-20",
+ "dev": "export COMMIT_HASH=$(git rev-parse HEAD) && yarn build && yarn database:migrate --local && yarn pages dev frontend/dist --d1 DATABASE --persist --compatibility-date=2022-12-20 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' 'ADMIN_EMAIL=george@test.email' --live-reload",
+ "ci-dev-test-ui": "yarn build && yarn database:create-mock && yarn pages dev frontend/dist --d1 DATABASE --persist --port 8788 --binding 'INSTANCE_TITLE=Test Wildebeest' 'INSTANCE_DESCR=My Wildebeest Instance' 'ACCESS_AUTH_DOMAIN=0.0.0.0.cloudflareaccess.com' 'ACCESS_AUD=DEV_AUD' 'ADMIN_EMAIL=george@test.email' --compatibility-date=2022-12-20",
"deploy:init": "yarn pages project create wildebeest && yarn d1 create wildebeest",
"deploy": "yarn build && yarn database:migrate && yarn pages publish frontend/dist --project-name=wildebeest"
},