feat: add site openapi schema + permissions + scheduler fixes

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

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

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

@ -5,6 +5,226 @@ import { CustomError } from '../helpers/common.js'
* Sites API Routes
*/
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(
'/',
{
@ -13,7 +233,14 @@ async function routes(app) {
},
schema: {
summary: 'List all sites',
tags: ['Sites']
tags: ['Sites'],
response: {
200: {
description: 'List of all sites',
type: 'array',
items: { $ref: 'siteSchema#' }
}
}
}
},
async () => {
@ -23,7 +250,11 @@ async function routes(app) {
id: s.id,
hostname: s.hostname,
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: {
type: 'object',
properties: {
siteId: {
siteIdorHostname: {
type: 'string',
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']
},
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
if (req.params.siteId === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({ hostname: req.hostname })
} else if (uuidValidate(req.params.siteId)) {
site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (req.params.siteIdorHostname === 'current' && req.hostname) {
site = await WIKI.models.sites.getSiteByHostname({
hostname: req.hostname,
strict: req.querystring?.strict ?? false
})
} else if (uuidValidate(req.params.siteIdorHostname)) {
site = await WIKI.models.sites.getSiteById({ id: req.params.siteIdorHostname })
} 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
? {
...site.config,
id: site.id,
hostname: site.hostname,
isEnabled: site.isEnabled
if (site) {
return {
...site.config,
id: site.id,
hostname: site.hostname,
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: {
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) => {

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

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

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

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

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

@ -162,7 +162,12 @@ async function initHTTPServer() {
const app = fastify({
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
logger: {
@ -320,10 +325,37 @@ async function initHTTPServer() {
}
},
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, {
routePrefix: '/_swagger'
routePrefix: '/_api',
logo: {}
})
// ----------------------------------------

@ -14,11 +14,13 @@ class Sites {
return WIKI.sites[id]
}
async getSiteByHostname({ hostname, forceReload = false }) {
async getSiteByHostname({ hostname, forceReload = false, strict = false }) {
if (forceReload) {
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) {
return WIKI.sites[siteId]
}

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

@ -616,60 +616,11 @@ watch(() => adminStore.currentSiteId, (newValue) => {
async function load () {
state.loading++
$q.loading.show()
const resp = await APOLLO_CLIENT.query({
query: `
query getSite (
$id: UUID!
) {
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)
const resp = await API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
state.config = {
...resp,
pageExtensions: resp.pageExtensions.join(',')
}
$q.loading.hide()
state.loading--
}

Loading…
Cancel
Save