feat: add site openapi schema + permissions + scheduler fixes

pull/7976/head
NGPixel 6 months ago
parent 6f91c2e052
commit debbb10ed8
No known key found for this signature in database

@ -1,17 +1,13 @@
/** /**
* API Routes * API Routes
*/ */
async function routes(app, options) { async function routes(app) {
app.register(import('./authentication.js')) app.register(import('./authentication.js'))
app.register(import('./locales.js'), { prefix: '/locales' }) app.register(import('./locales.js'), { prefix: '/locales' })
app.register(import('./pages.js')) app.register(import('./pages.js'))
app.register(import('./sites.js'), { prefix: '/sites' }) app.register(import('./sites.js'), { prefix: '/sites' })
app.register(import('./system.js'), { prefix: '/system' }) app.register(import('./system.js'), { prefix: '/system' })
app.register(import('./users.js'), { prefix: '/users' }) app.register(import('./users.js'), { prefix: '/users' })
app.get('/', async (req, reply) => {
return { ok: true }
})
} }
export default routes export default routes

@ -5,6 +5,9 @@ async function routes(app, options) {
app.get( app.get(
'/sites/:siteId/pages', '/sites/:siteId/pages',
{ {
config: {
permissions: ['read:pages', 'manage:pages']
},
schema: { schema: {
summary: 'List all pages', summary: 'List all pages',
tags: ['Pages'], tags: ['Pages'],
@ -28,7 +31,7 @@ async function routes(app, options) {
'/sites/:siteId/pages/:pageIdOrHash', '/sites/:siteId/pages/:pageIdOrHash',
{ {
schema: { schema: {
summary: 'List all pages', summary: 'Get a single page',
tags: ['Pages'], tags: ['Pages'],
params: { params: {
type: 'object', type: 'object',

@ -5,6 +5,226 @@ import { CustomError } from '../helpers/common.js'
* Sites API Routes * Sites API Routes
*/ */
async function routes(app) { async function routes(app) {
app.addSchema({
$id: 'siteSchema',
type: 'object',
properties: {
id: {
type: 'string',
format: 'uuid'
},
hostname: {
type: 'string',
format: 'hostname'
},
isEnabled: {
type: 'boolean'
},
title: {
type: 'string'
},
description: {
type: 'string'
},
company: {
type: 'string'
},
contentLicense: {
type: 'string'
},
footerExtra: {
type: 'string'
},
pageExtensions: {
type: 'array',
items: {
type: 'string'
}
},
pageCasing: {
type: 'boolean'
},
discoverable: {
type: 'boolean'
},
defaults: {
type: 'object',
properties: {
tocDepth: {
type: 'object',
properties: {
min: {
type: 'number'
},
max: {
type: 'number'
}
}
}
}
},
features: {
type: 'object',
properties: {
browse: {
type: 'boolean'
},
ratings: {
type: 'boolean'
},
ratingsMode: {
type: 'string',
enum: ['off', 'stars', 'thumbs']
},
comments: {
type: 'boolean'
},
contributions: {
type: 'boolean'
},
profile: {
type: 'boolean'
},
search: {
type: 'boolean'
}
}
},
logoUrl: {
type: 'string'
},
logoText: {
type: 'boolean'
},
sitemap: {
type: 'boolean'
},
robots: {
type: 'object',
properties: {
index: {
type: 'boolean'
},
follow: {
type: 'boolean'
}
}
},
locales: {
type: 'object',
properties: {
primary: {
type: 'string'
},
active: {
type: 'array',
items: {
type: 'string'
}
}
}
},
assets: {
type: 'object',
properties: {
logo: {
type: 'boolean'
},
logoExt: {
type: 'string'
},
favicon: {
type: 'boolean'
},
faviconExt: {
type: 'string'
},
loginBg: {
type: 'boolean'
}
}
},
editors: {
type: 'object',
properties: {
asciidoc: {
type: 'boolean'
},
markdown: {
type: 'boolean'
},
wysiwyg: {
type: 'boolean'
}
}
},
theme: {
type: 'object',
properties: {
dark: {
type: 'boolean'
},
codeBlocksTheme: {
type: 'string',
format: 'hexcolor'
},
colorPrimary: {
type: 'string',
format: 'hexcolor'
},
colorSecondary: {
type: 'string',
format: 'hexcolor'
},
colorAccent: {
type: 'string',
format: 'hexcolor'
},
colorHeader: {
type: 'string',
format: 'hexcolor'
},
colorSidebar: {
type: 'string',
format: 'hexcolor'
},
injectCSS: {
type: 'string'
},
injectHead: {
type: 'string'
},
injectBody: {
type: 'string'
},
contentWidth: {
type: 'string',
enum: ['centered', 'full']
},
sidebarPosition: {
type: 'string',
enum: ['off', 'left', 'right']
},
tocPosition: {
type: 'string',
enum: ['off', 'left', 'right']
},
showSharingMenu: {
type: 'boolean'
},
showPrintBtn: {
type: 'boolean'
},
baseFont: {
type: 'string'
},
contentFont: {
type: 'string'
}
}
}
}
})
app.get( app.get(
'/', '/',
{ {
@ -13,7 +233,14 @@ async function routes(app) {
}, },
schema: { schema: {
summary: 'List all sites', summary: 'List all sites',
tags: ['Sites'] tags: ['Sites'],
response: {
200: {
description: 'List of all sites',
type: 'array',
items: { $ref: 'siteSchema#' }
}
}
} }
}, },
async () => { async () => {
@ -23,7 +250,11 @@ async function routes(app) {
id: s.id, id: s.id,
hostname: s.hostname, hostname: s.hostname,
isEnabled: s.isEnabled, isEnabled: s.isEnabled,
pageExtensions: s.config.pageExtensions.join(', ') editors: {
asciidoc: s.config.editors?.asciidoc?.isActive ?? false,
markdown: s.config.editors?.markdown?.isActive ?? false,
wysiwyg: s.config.editors?.wysiwyg?.isActive ?? false
}
})) }))
} }
) )
@ -37,33 +268,64 @@ async function routes(app) {
params: { params: {
type: 'object', type: 'object',
properties: { properties: {
siteId: { siteIdorHostname: {
type: 'string', type: 'string',
description: 'Either a site ID, hostname or "current" to use the request hostname.', description: 'Either a site ID, hostname or "current" to use the request hostname.',
oneOf: [{ format: 'uuid' }, { enum: ['current'] }, { pattern: '^[a-f0-9]+$' }] anyOf: [{ format: 'uuid' }, { enum: ['current'] }, { pattern: '^[a-z0-9.-]+$' }]
} }
}, },
required: ['siteIdorHostname'] required: ['siteIdorHostname']
},
querystring: {
type: 'object',
properties: {
strict: {
type: 'boolean',
description:
'Whether to only return a site that exactly matches the hostname. Wildcard sites will not be matched.',
default: false
}
}
},
response: {
200: {
description: 'Site info',
type: 'object',
$ref: 'siteSchema#'
}
} }
} }
}, },
async (req) => { async (req, reply) => {
let site let site
if (req.params.siteId === 'current' && req.hostname) { if (req.params.siteIdorHostname === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname }) site = await WIKI.models.sites.getSiteByHostname({
} else if (uuidValidate(req.params.siteId)) { hostname: req.hostname,
site = await WIKI.models.sites.getSiteById({ id: req.params.siteId }) strict: req.querystring?.strict ?? false
})
} else if (uuidValidate(req.params.siteIdorHostname)) {
site = await WIKI.models.sites.getSiteById({ id: req.params.siteIdorHostname })
} else { } else {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.params.siteId }) site = await WIKI.models.sites.getSiteByHostname({
hostname: req.params.siteIdorHostname,
strict: req.querystring?.strict ?? false
})
} }
return site if (site) {
? { return {
...site.config, ...site.config,
id: site.id, id: site.id,
hostname: site.hostname, hostname: site.hostname,
isEnabled: site.isEnabled isEnabled: site.isEnabled,
editors: {
asciidoc: site.config.editors?.asciidoc?.isActive ?? false,
markdown: site.config.editors?.markdown?.isActive ?? false,
wysiwyg: site.config.editors?.wysiwyg?.isActive ?? false
} }
: null }
} else {
return reply.notFound('Site does not exist.')
}
} }
) )
@ -178,7 +440,32 @@ async function routes(app) {
}, },
schema: { schema: {
summary: 'Update a site', summary: 'Update a site',
tags: ['Sites'] tags: ['Sites'],
body: {
type: 'object',
properties: {
isEnabled: {
type: 'boolean'
},
hostname: {
type: 'string',
minLength: 1,
maxLength: 255,
pattern: '^(\\*|[a-z0-9.-]+)$'
},
title: {
type: 'string',
minLength: 1,
maxLength: 255
}
},
examples: [
{
hostname: 'wiki.example.org',
title: 'My Wiki Site'
}
]
}
} }
}, },
async (req, reply) => { async (req, reply) => {

@ -5,6 +5,9 @@ async function routes(app, options) {
app.get( app.get(
'/', '/',
{ {
config: {
permissions: ['read:users', 'manage:users']
},
schema: { schema: {
summary: 'List all users', summary: 'List all users',
tags: ['Users'] tags: ['Users']
@ -42,6 +45,9 @@ async function routes(app, options) {
app.get( app.get(
'/:userId', '/:userId',
{ {
config: {
permissions: ['read:users', 'manage:users']
},
schema: { schema: {
summary: 'Get user info', summary: 'Get user info',
tags: ['Users'] tags: ['Users']
@ -55,6 +61,9 @@ async function routes(app, options) {
app.post( app.post(
'/', '/',
{ {
config: {
permissions: ['create:users', 'manage:users']
},
schema: { schema: {
summary: 'Create a new user', summary: 'Create a new user',
tags: ['Users'] tags: ['Users']
@ -68,6 +77,9 @@ async function routes(app, options) {
app.put( app.put(
'/:userId', '/:userId',
{ {
config: {
permissions: ['manage:users']
},
schema: { schema: {
summary: 'Update a user', summary: 'Update a user',
tags: ['Users'] tags: ['Users']
@ -81,6 +93,9 @@ async function routes(app, options) {
app.delete( app.delete(
'/:userId', '/:userId',
{ {
config: {
permissions: ['manage:users']
},
schema: { schema: {
summary: 'Delete a user', summary: 'Delete a user',
tags: ['Users'] tags: ['Users']

@ -11,7 +11,8 @@ import { remove } from 'es-toolkit/array'
import { import {
jobs as jobsTable, jobs as jobsTable,
jobLock as jobLockTable, jobLock as jobLockTable,
jobSchedule as jobScheduleTable jobSchedule as jobScheduleTable,
jobHistory as jobHistoryTable
} from '../db/schema.js' } from '../db/schema.js'
import { eq, inArray, sql } from 'drizzle-orm' import { eq, inArray, sql } from 'drizzle-orm'
@ -192,8 +193,8 @@ export default {
WIKI.logger.info(`Processing new job ${job.id}: ${job.task}...`) WIKI.logger.info(`Processing new job ${job.id}: ${job.task}...`)
// -> Add to Job History // -> Add to Job History
await WIKI.db await WIKI.db
.knex('jobHistory') .insert(jobHistoryTable)
.insert({ .values({
id: job.id, id: job.id,
task: job.task, task: job.task,
state: 'active', state: 'active',
@ -205,10 +206,9 @@ export default {
executedBy: WIKI.INSTANCE_ID, executedBy: WIKI.INSTANCE_ID,
createdAt: job.createdAt createdAt: job.createdAt
}) })
.onConflict('id') .onConflictDoUpdate({
.merge({ target: jobHistoryTable.id,
executedBy: WIKI.INSTANCE_ID, set: { executedBy: WIKI.INSTANCE_ID, startedAt: sql`now()` }
startedAt: new Date()
}) })
jobIds.push(job.id) jobIds.push(job.id)
@ -224,14 +224,12 @@ export default {
} }
// -> Update job history (success) // -> Update job history (success)
await WIKI.db await WIKI.db
.knex('jobHistory') .update(jobHistoryTable)
.where({ .set({
id: job.id
})
.update({
state: 'completed', state: 'completed',
completedAt: new Date() completedAt: sql`now()`
}) })
.where(eq(jobHistoryTable.id, job.id))
WIKI.logger.info(`Completed job ${job.id}: ${job.task}`) WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [ this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [
'scheduler', 'scheduler',
@ -247,15 +245,13 @@ export default {
WIKI.logger.warn(err) WIKI.logger.warn(err)
// -> Update job history (fail) // -> Update job history (fail)
await WIKI.db await WIKI.db
.knex('jobHistory') .update(jobHistoryTable)
.where({ .set({
id: job.id
})
.update({
attempt: job.retries + 1, attempt: job.retries + 1,
state: 'failed', state: 'failed',
lastErrorMessage: err.message lastErrorMessage: err.message
}) })
.where(eq(jobHistoryTable.id, job.id))
this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [ this.pubsubClient.query(`SELECT pg_notify($1, $2)`, [
'scheduler', 'scheduler',
JSON.stringify({ JSON.stringify({
@ -269,7 +265,7 @@ export default {
// -> Reschedule for retry // -> Reschedule for retry
if (job.retries < job.maxRetries) { if (job.retries < job.maxRetries) {
const backoffDelay = 2 ** job.retries * WIKI.config.scheduler.retryBackoff const backoffDelay = 2 ** job.retries * WIKI.config.scheduler.retryBackoff
await trx('jobs').insert({ await trx.insert(jobsTable).values({
...job, ...job,
retries: job.retries + 1, retries: job.retries + 1,
waitUntil: DateTime.utc().plus({ seconds: backoffDelay }).toJSDate(), waitUntil: DateTime.utc().plus({ seconds: backoffDelay }).toJSDate(),
@ -284,10 +280,13 @@ export default {
} catch (err) { } catch (err) {
WIKI.logger.warn(err) WIKI.logger.warn(err)
if (jobIds && jobIds.length > 0) { if (jobIds && jobIds.length > 0) {
WIKI.db.knex('jobHistory').whereIn('id', jobIds).update({ WIKI.db
state: 'interrupted', .update(jobHistoryTable)
lastErrorMessage: err.message .set({
}) state: 'interrupted',
lastErrorMessage: err.message
})
.where(inArray(jobsTable.id, jobIds))
} }
} }
}, },

@ -78,9 +78,9 @@ CREATE TABLE "jobHistory" (
"maxRetries" integer DEFAULT 0 NOT NULL, "maxRetries" integer DEFAULT 0 NOT NULL,
"lastErrorMessage" text, "lastErrorMessage" text,
"executedBy" varchar(255), "executedBy" varchar(255),
"createdAt" timestamp DEFAULT now() NOT NULL, "createdAt" timestamp NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL, "startedAt" timestamp DEFAULT now() NOT NULL,
"completedAt" timestamp NOT NULL "completedAt" timestamp
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "jobLock" ( CREATE TABLE "jobLock" (

@ -1,7 +1,7 @@
{ {
"version": "8", "version": "8",
"dialect": "postgres", "dialect": "postgres",
"id": "1491778a-3cba-43df-815f-398b66d95ed5", "id": "91c32c3a-9a9e-4693-b10f-694beca33f26",
"prevIds": [ "prevIds": [
"061e8c84-e05e-40b0-a074-7a56bd794fc7" "061e8c84-e05e-40b0-a074-7a56bd794fc7"
], ],
@ -957,7 +957,7 @@
"typeSchema": null, "typeSchema": null,
"notNull": true, "notNull": true,
"dimensions": 0, "dimensions": 0,
"default": "now()", "default": null,
"generated": null, "generated": null,
"identity": null, "identity": null,
"name": "createdAt", "name": "createdAt",
@ -973,7 +973,7 @@
"default": "now()", "default": "now()",
"generated": null, "generated": null,
"identity": null, "identity": null,
"name": "updatedAt", "name": "startedAt",
"entityType": "columns", "entityType": "columns",
"schema": "public", "schema": "public",
"table": "jobHistory" "table": "jobHistory"
@ -981,7 +981,7 @@
{ {
"type": "timestamp", "type": "timestamp",
"typeSchema": null, "typeSchema": null,
"notNull": true, "notNull": false,
"dimensions": 0, "dimensions": 0,
"default": null, "default": null,
"generated": null, "generated": null,

@ -134,9 +134,9 @@ export const jobHistory = pgTable('jobHistory', {
maxRetries: integer().notNull().default(0), maxRetries: integer().notNull().default(0),
lastErrorMessage: text(), lastErrorMessage: text(),
executedBy: varchar({ length: 255 }), executedBy: varchar({ length: 255 }),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull(),
updatedAt: timestamp().notNull().defaultNow(), startedAt: timestamp().notNull().defaultNow(),
completedAt: timestamp().notNull() completedAt: timestamp()
}) })
// JOB SCHEDULE ------------------------ // JOB SCHEDULE ------------------------

@ -162,7 +162,12 @@ async function initHTTPServer() {
const app = fastify({ const app = fastify({
ajv: { ajv: {
plugins: [[ajvFormats, {}]] plugins: [[ajvFormats, {}]],
onCreate: (ajv) => {
ajv.addFormat('hexcolor', (data) => {
return typeof data === 'string' && data.test(/#[a-fA-F0-9]{6}/)
})
}
}, },
bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb bodyLimit: WIKI.config.bodyParserLimit || 5242880, // 5mb
logger: { logger: {
@ -320,10 +325,37 @@ async function initHTTPServer() {
} }
}, },
security: [{ apiKeyAuth: [] }, { bearerAuth: [] }] security: [{ apiKeyAuth: [] }, { bearerAuth: [] }]
},
transform: ({ schema, url, route }) => {
// Add permissions to the route schema description
const permissions = route?.config?.permissions ?? []
const transformedSchema = { ...schema }
const currentDescription = transformedSchema.description || ''
if (permissions?.length > 0) {
const nestedPermissions = []
for (const perm of permissions) {
if (Array.isArray(perm)) {
nestedPermissions.push(`\`${perm.join(' + ')}\``)
} else {
nestedPermissions.push(`\`${perm}\``)
}
}
nestedPermissions.push('`manage:system`')
transformedSchema.description =
`${currentDescription}\n\n**Required Permissions:** ${nestedPermissions.join(' or ')}`.trim()
transformedSchema['x-permissions'] = permissions
} else {
transformedSchema.description =
`${currentDescription}\n\n**This API is public.** No special permissions required.`.trim()
}
return { schema: transformedSchema, url }
} }
}) })
app.register(fastifySwaggerUi, { app.register(fastifySwaggerUi, {
routePrefix: '/_swagger' routePrefix: '/_api',
logo: {}
}) })
// ---------------------------------------- // ----------------------------------------

@ -14,11 +14,13 @@ class Sites {
return WIKI.sites[id] return WIKI.sites[id]
} }
async getSiteByHostname({ hostname, forceReload = false }) { async getSiteByHostname({ hostname, forceReload = false, strict = false }) {
if (forceReload) { if (forceReload) {
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()
} }
const siteId = WIKI.sitesMappings[hostname] || WIKI.sitesMappings['*'] const siteId = strict
? WIKI.sitesMappings[hostname]
: WIKI.sitesMappings[hostname] || WIKI.sitesMappings['*']
if (siteId) { if (siteId) {
return WIKI.sites[siteId] return WIKI.sites[siteId]
} }

@ -80,31 +80,12 @@ const state = reactive({
async function confirm () { async function confirm () {
state.isLoading = true state.isLoading = true
try { try {
const resp = await APOLLO_CLIENT.mutate({ const resp = await API_CLIENT.put(`sites/${props.site.id}`, {
mutation: ` json: {
mutation updateSite ( isEnabled: props.targetState
$id: UUID!
$newState: Boolean
) {
updateSite(
id: $id
patch: {
isEnabled: $newState
}
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.site.id,
newState: props.targetState
} }
}) })
if (resp?.data?.updateSite?.operation?.succeeded) { if (resp?.ok) {
$q.notify({ $q.notify({
type: 'positive', type: 'positive',
message: t('admin.sites.updateSuccess') message: t('admin.sites.updateSuccess')
@ -122,7 +103,7 @@ async function confirm () {
}) })
onDialogOK() onDialogOK()
} else { } else {
throw new Error(resp?.data?.updateSite?.operation?.message || 'An unexpected error occured.') throw new Error(t(`admin.sites.${resp?.error}`, resp?.message || 'An unexpected error occured.'))
} }
} catch (err) { } catch (err) {
$q.notify({ $q.notify({

@ -616,60 +616,11 @@ watch(() => adminStore.currentSiteId, (newValue) => {
async function load () { async function load () {
state.loading++ state.loading++
$q.loading.show() $q.loading.show()
const resp = await APOLLO_CLIENT.query({ const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
query: ` state.config = {
query getSite ( ...resp,
$id: UUID! pageExtensions: resp.pageExtensions.join(',')
) { }
siteById(
id: $id
) {
id
hostname
isEnabled
title
description
company
contentLicense
footerExtra
pageExtensions
pageCasing
logoText
sitemap
uploads {
conflictBehavior
normalizeFilename
}
robots {
index
follow
}
features {
browse
comments
contributions
profile
ratings
ratingsMode
reasonForChange
search
}
discoverable
defaults {
tocDepth {
min
max
}
}
}
}
`,
variables: {
id: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.config = cloneDeep(resp?.data?.siteById)
$q.loading.hide() $q.loading.hide()
state.loading-- state.loading--
} }

Loading…
Cancel
Save