diff --git a/backend/api/index.js b/backend/api/index.js index 10faa41e..5e28a28e 100644 --- a/backend/api/index.js +++ b/backend/api/index.js @@ -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 diff --git a/backend/api/pages.js b/backend/api/pages.js index c92faa75..0a791739 100644 --- a/backend/api/pages.js +++ b/backend/api/pages.js @@ -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', diff --git a/backend/api/sites.js b/backend/api/sites.js index 80623a2f..58c3083a 100644 --- a/backend/api/sites.js +++ b/backend/api/sites.js @@ -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) => { diff --git a/backend/api/users.js b/backend/api/users.js index bfc13f7b..f7922ab8 100644 --- a/backend/api/users.js +++ b/backend/api/users.js @@ -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'] diff --git a/backend/core/scheduler.js b/backend/core/scheduler.js index 3c133d25..30b0dbdc 100644 --- a/backend/core/scheduler.js +++ b/backend/core/scheduler.js @@ -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)) } } }, diff --git a/backend/db/migrations/20260322001113_main/migration.sql b/backend/db/migrations/20260330022921_main/migration.sql similarity index 98% rename from backend/db/migrations/20260322001113_main/migration.sql rename to backend/db/migrations/20260330022921_main/migration.sql index 3218ceba..0fa568c9 100644 --- a/backend/db/migrations/20260322001113_main/migration.sql +++ b/backend/db/migrations/20260330022921_main/migration.sql @@ -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" ( diff --git a/backend/db/migrations/20260322001113_main/snapshot.json b/backend/db/migrations/20260330022921_main/snapshot.json similarity index 99% rename from backend/db/migrations/20260322001113_main/snapshot.json rename to backend/db/migrations/20260330022921_main/snapshot.json index b271b6ba..1525f982 100644 --- a/backend/db/migrations/20260322001113_main/snapshot.json +++ b/backend/db/migrations/20260330022921_main/snapshot.json @@ -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, diff --git a/backend/db/schema.js b/backend/db/schema.js index 0354eb85..c0ec5e15 100644 --- a/backend/db/schema.js +++ b/backend/db/schema.js @@ -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 ------------------------ diff --git a/backend/index.js b/backend/index.js index 86a21866..f10b689d 100644 --- a/backend/index.js +++ b/backend/index.js @@ -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: {} }) // ---------------------------------------- diff --git a/backend/models/sites.js b/backend/models/sites.js index b09531fc..12f9f2f2 100644 --- a/backend/models/sites.js +++ b/backend/models/sites.js @@ -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] } diff --git a/frontend/src/components/SiteActivateDialog.vue b/frontend/src/components/SiteActivateDialog.vue index 4d51c3dc..b9856865 100644 --- a/frontend/src/components/SiteActivateDialog.vue +++ b/frontend/src/components/SiteActivateDialog.vue @@ -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({ diff --git a/frontend/src/pages/AdminGeneral.vue b/frontend/src/pages/AdminGeneral.vue index c3454d34..bfc263c0 100644 --- a/frontend/src/pages/AdminGeneral.vue +++ b/frontend/src/pages/AdminGeneral.vue @@ -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-- }