mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
448 lines
14 KiB
448 lines
14 KiB
import { mergeWith, toMerged } from 'es-toolkit/object'
|
|
import { keyBy } from 'es-toolkit/array'
|
|
import {
|
|
blocks as blocksTable,
|
|
siteAssets as siteAssetsTable,
|
|
sites as sitesTable,
|
|
storage as storageTable
|
|
} from '../db/schema.ts'
|
|
import { and, eq } from 'drizzle-orm'
|
|
import { detectImageMime, detectSvg, normalizeImage, svgMimeType } from '../helpers/images.ts'
|
|
import type { ImageNormalization } from '../helpers/images.ts'
|
|
import type { SystemIds } from './types.ts'
|
|
|
|
/**
|
|
* The images a site can have uploaded for it. Each name is also the flag in the site's
|
|
* `config.assets` saying whether there is one — which is what the cached site config is asked before
|
|
* the bytes are ever looked up — and the name the image is addressed by, both to upload it and to
|
|
* serve it.
|
|
*/
|
|
export const siteAssetKinds = ['logo', 'favicon', 'loginBg'] as const
|
|
|
|
export type SiteAssetKind = (typeof siteAssetKinds)[number]
|
|
|
|
/**
|
|
* The size and format each image is stored at, i.e. what a browser is eventually handed. Every one
|
|
* is far smaller than what an administrator is likely to upload: these are a header logo, a tab icon
|
|
* and a login backdrop, not artwork to be kept at its original resolution.
|
|
*/
|
|
const SITE_ASSET_NORMALIZATION: Record<SiteAssetKind, ImageNormalization> = {
|
|
// -> A logo is whatever shape its owner made it, so it is fitted rather than cropped
|
|
logo: { width: 512, height: 512, fit: 'inside', format: 'webp' },
|
|
// -> PNG rather than WebP: a favicon is read by whatever the browser's tab strip, bookmark list and
|
|
// home screen are made of, some of it much older than the page itself
|
|
favicon: { width: 180, height: 180, fit: 'cover', format: 'png' },
|
|
loginBg: { width: 1920, height: 1080, fit: 'cover', format: 'webp' }
|
|
}
|
|
|
|
/**
|
|
* Sites model
|
|
*/
|
|
class Sites {
|
|
async getSiteById({ id, forceReload = false }: { id: string; forceReload?: boolean }) {
|
|
if (forceReload) {
|
|
await WIKI.models.sites.reloadCache()
|
|
}
|
|
return WIKI.sites[id]
|
|
}
|
|
|
|
async getSiteByHostname({
|
|
hostname,
|
|
forceReload = false,
|
|
strict = false
|
|
}: {
|
|
hostname: string
|
|
forceReload?: boolean
|
|
strict?: boolean
|
|
}) {
|
|
if (forceReload) {
|
|
await WIKI.models.sites.reloadCache()
|
|
}
|
|
const siteId = strict
|
|
? WIKI.sitesMappings[hostname]
|
|
: WIKI.sitesMappings[hostname] || WIKI.sitesMappings['*']
|
|
if (siteId) {
|
|
return WIKI.sites[siteId]
|
|
}
|
|
return null
|
|
}
|
|
|
|
async isHostnameUnique(hostname: string): Promise<boolean> {
|
|
return (await WIKI.db.$count(sitesTable, eq(sitesTable.hostname, hostname))) === 0
|
|
}
|
|
|
|
async getAllSites() {
|
|
return WIKI.db.select().from(sitesTable).orderBy(sitesTable.hostname)
|
|
}
|
|
|
|
async reloadCache(): Promise<void> {
|
|
WIKI.logger.info('Reloading site configurations...')
|
|
const sites = await WIKI.db.select().from(sitesTable).orderBy(sitesTable.id)
|
|
WIKI.sites = keyBy(sites, (s) => s.id)
|
|
WIKI.sitesMappings = {}
|
|
for (const site of sites) {
|
|
WIKI.sitesMappings[site.hostname] = site.id
|
|
}
|
|
WIKI.logger.info(`Loaded ${sites.length} site configurations [ OK ]`)
|
|
}
|
|
|
|
async createSite(hostname: string, config: Record<string, any> = {}) {
|
|
const result = await WIKI.db
|
|
.insert(sitesTable)
|
|
.values({
|
|
hostname,
|
|
isEnabled: true,
|
|
config: toMerged(
|
|
{
|
|
title: 'My Wiki Site',
|
|
description: '',
|
|
company: '',
|
|
contentLicense: '',
|
|
footerExtra: '',
|
|
pageExtensions: ['md', 'html', 'txt'],
|
|
discoverable: false,
|
|
defaults: {
|
|
tocDepth: {
|
|
min: 1,
|
|
max: 2
|
|
}
|
|
},
|
|
features: {
|
|
browse: true,
|
|
collaborativeEditing: true,
|
|
ratings: false,
|
|
ratingsMode: 'off',
|
|
comments: false,
|
|
profile: true,
|
|
reasonForChange: 'optional',
|
|
search: true
|
|
},
|
|
logoUrl: '',
|
|
logoText: true,
|
|
sitemap: true,
|
|
robots: {
|
|
index: true,
|
|
follow: true
|
|
},
|
|
// -> Local authentication is the only strategy guaranteed to exist at this point
|
|
authStrategies: [{ id: WIKI.data.systemIds.localAuthId, order: 0, isVisible: true }],
|
|
auth: {
|
|
autoLogin: false,
|
|
bypassUnauthorized: false,
|
|
hideLocal: false,
|
|
loginRedirect: '/',
|
|
welcomeRedirect: '/',
|
|
logoutRedirect: '/'
|
|
},
|
|
locales: {
|
|
primary: 'en',
|
|
active: ['en'],
|
|
forcePrefix: false,
|
|
showMenu: true
|
|
},
|
|
assets: {
|
|
logo: false,
|
|
favicon: false,
|
|
loginBg: false
|
|
},
|
|
theme: {
|
|
dark: false,
|
|
codeBlocksTheme: 'github-dark',
|
|
colorPrimary: '#1976D2',
|
|
colorSecondary: '#02C39A',
|
|
colorAccent: '#FF9800',
|
|
colorHeader: '#000000',
|
|
colorSidebar: '#1976D2',
|
|
injectCSS: '',
|
|
injectHead: '',
|
|
injectBody: '',
|
|
contentWidth: 'full',
|
|
sidebarPosition: 'left',
|
|
tocPosition: 'right',
|
|
showPrintBtn: true,
|
|
baseFont: 'roboto',
|
|
contentFont: 'roboto'
|
|
},
|
|
editors: {
|
|
asciidoc: {
|
|
isActive: true,
|
|
config: {}
|
|
},
|
|
markdown: {
|
|
isActive: true,
|
|
config: {
|
|
allowHTML: true,
|
|
lineBreaks: true,
|
|
linkify: true,
|
|
multimdTable: true,
|
|
quotes: 'english',
|
|
tabWidth: 2,
|
|
typographer: false,
|
|
underline: true
|
|
}
|
|
},
|
|
wysiwyg: {
|
|
isActive: true,
|
|
config: {}
|
|
}
|
|
},
|
|
uploads: {
|
|
conflictBehavior: 'overwrite'
|
|
}
|
|
},
|
|
config
|
|
)
|
|
})
|
|
.returning({ id: sitesTable.id })
|
|
|
|
const newSite = result[0]
|
|
|
|
// -> The menu every page of the site inherits, keyed by the site id. Empty to begin with, but it
|
|
// has to exist before a page can point at it
|
|
WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`)
|
|
await WIKI.models.navigation.ensureSiteNav(newSite.id)
|
|
|
|
// -> Site lookups by id / hostname are served from cache, which must know about the new site
|
|
await WIKI.models.sites.reloadCache()
|
|
|
|
// -> Otherwise the new site would have no blocks until the next restart
|
|
await WIKI.models.blocks.syncSite(newSite.id)
|
|
|
|
// -> Same for storage: the site needs its database target from the moment it can hold content
|
|
await WIKI.models.storage.syncSite(newSite.id)
|
|
|
|
return newSite
|
|
}
|
|
|
|
async updateSite(
|
|
id: string,
|
|
patch: { hostname?: string; isEnabled?: boolean; config?: Record<string, any> }
|
|
): Promise<boolean> {
|
|
const values: Partial<typeof sitesTable.$inferInsert> = {}
|
|
if (patch.hostname !== undefined) {
|
|
values.hostname = patch.hostname
|
|
}
|
|
if (patch.isEnabled !== undefined) {
|
|
values.isEnabled = patch.isEnabled
|
|
}
|
|
if (patch.config) {
|
|
// -> Config is a JSONB blob, so it must be read and merged rather than partially assigned.
|
|
// Arrays are replaced rather than merged index-wise, otherwise removing an entry (e.g. a page
|
|
// extension) would leave the original value in place.
|
|
const current = await WIKI.db
|
|
.select({ config: sitesTable.config })
|
|
.from(sitesTable)
|
|
.where(eq(sitesTable.id, id))
|
|
if (current.length < 1) {
|
|
return false
|
|
}
|
|
values.config = mergeWith(
|
|
current[0].config as Record<string, any>,
|
|
patch.config,
|
|
(_targetValue, sourceValue) => (Array.isArray(sourceValue) ? sourceValue : undefined)
|
|
)
|
|
}
|
|
if (Object.keys(values).length < 1) {
|
|
return false
|
|
}
|
|
|
|
const updatedResult = await WIKI.db.update(sitesTable).set(values).where(eq(sitesTable.id, id))
|
|
if ((updatedResult.rowCount ?? 0) < 1) {
|
|
return false
|
|
}
|
|
|
|
await WIKI.models.sites.reloadCache()
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* The bytes of an image uploaded for a site, if there is one.
|
|
*
|
|
* What was stored depends on what the upload could be normalized to — Sharp is an optional
|
|
* extension, and an SVG is never re-encoded at all — so the type is read back off the bytes rather
|
|
* than assumed.
|
|
*/
|
|
async getAsset(
|
|
siteId: string,
|
|
kind: SiteAssetKind
|
|
): Promise<{ data: Buffer; mime: string } | null> {
|
|
const rows = await WIKI.db
|
|
.select({ data: siteAssetsTable.data })
|
|
.from(siteAssetsTable)
|
|
.where(and(eq(siteAssetsTable.siteId, siteId), eq(siteAssetsTable.kind, kind)))
|
|
.limit(1)
|
|
const data = rows[0]?.data
|
|
if (!data) {
|
|
return null
|
|
}
|
|
const mime =
|
|
detectImageMime(data) ?? (detectSvg(data) ? svgMimeType : 'application/octet-stream')
|
|
return { data, mime }
|
|
}
|
|
|
|
/**
|
|
* Replace one of a site's images.
|
|
*
|
|
* A raster upload is brought down to the size and format it will be served at, per
|
|
* `SITE_ASSET_NORMALIZATION` — there is no reason to hand every visitor the multi-megabyte
|
|
* original of an image displayed 34 pixels tall. That needs the Sharp extension, so without it the
|
|
* uploaded bytes are stored as they came in, which is what the admin area's "requires Sharp"
|
|
* indicator is warning about. An SVG is stored as it came in either way: it is markup, it already
|
|
* scales to any size, and rasterizing it would throw away the only reason to use one.
|
|
*
|
|
* @param data The uploaded image, already known to be one of the supported formats
|
|
*/
|
|
async setAsset(siteId: string, kind: SiteAssetKind, data: Buffer): Promise<void> {
|
|
const normalized = detectSvg(data)
|
|
? data
|
|
: ((await normalizeImage(data, SITE_ASSET_NORMALIZATION[kind])) ?? data)
|
|
await WIKI.db
|
|
.insert(siteAssetsTable)
|
|
.values({ siteId, kind, data: normalized })
|
|
.onConflictDoUpdate({
|
|
target: [siteAssetsTable.siteId, siteAssetsTable.kind],
|
|
set: { data: normalized }
|
|
})
|
|
// -> Serving reads this flag off the cached site config before it looks for any bytes
|
|
await WIKI.models.sites.updateSite(siteId, { config: { assets: { [kind]: true } } })
|
|
}
|
|
|
|
/**
|
|
* Remove one of a site's images, leaving the built-in default to be served again.
|
|
*/
|
|
async clearAsset(siteId: string, kind: SiteAssetKind): Promise<void> {
|
|
await WIKI.db
|
|
.delete(siteAssetsTable)
|
|
.where(and(eq(siteAssetsTable.siteId, siteId), eq(siteAssetsTable.kind, kind)))
|
|
await WIKI.models.sites.updateSite(siteId, { config: { assets: { [kind]: false } } })
|
|
}
|
|
|
|
async deleteSite(id: string): Promise<boolean> {
|
|
// -> Block, storage and uploaded image rows belong to the site rather than to its content, and
|
|
// their FK has no cascade, so they would otherwise block the delete. Content tables (pages,
|
|
// assets, ...) deliberately still do — see the conflict handling in the route.
|
|
await WIKI.db.delete(blocksTable).where(eq(blocksTable.siteId, id))
|
|
await WIKI.db.delete(storageTable).where(eq(storageTable.siteId, id))
|
|
await WIKI.db.delete(siteAssetsTable).where(eq(siteAssetsTable.siteId, id))
|
|
|
|
const deletedResult = await WIKI.db.delete(sitesTable).where(eq(sitesTable.id, id))
|
|
if ((deletedResult.rowCount ?? 0) < 1) {
|
|
return false
|
|
}
|
|
|
|
await WIKI.models.sites.reloadCache()
|
|
return true
|
|
}
|
|
|
|
async countSites() {
|
|
return WIKI.db.$count(sitesTable)
|
|
}
|
|
|
|
async init(ids: SystemIds): Promise<void> {
|
|
WIKI.logger.info('Inserting default site...')
|
|
|
|
await WIKI.db.insert(sitesTable).values({
|
|
id: ids.siteId,
|
|
hostname: '*',
|
|
isEnabled: true,
|
|
config: {
|
|
title: 'Default Site',
|
|
description: '',
|
|
company: '',
|
|
contentLicense: '',
|
|
footerExtra: '',
|
|
pageExtensions: ['md', 'html', 'txt'],
|
|
discoverable: false,
|
|
defaults: {
|
|
tocDepth: {
|
|
min: 1,
|
|
max: 2
|
|
}
|
|
},
|
|
features: {
|
|
browse: true,
|
|
collaborativeEditing: true,
|
|
ratings: false,
|
|
ratingsMode: 'off',
|
|
comments: false,
|
|
profile: true,
|
|
reasonForChange: 'optional',
|
|
search: true
|
|
},
|
|
logoText: true,
|
|
sitemap: true,
|
|
robots: {
|
|
index: true,
|
|
follow: true
|
|
},
|
|
authStrategies: [{ id: ids.authModuleId, order: 0, isVisible: true }],
|
|
auth: {
|
|
autoLogin: false,
|
|
bypassUnauthorized: false,
|
|
hideLocal: false,
|
|
loginRedirect: '/',
|
|
welcomeRedirect: '/',
|
|
logoutRedirect: '/'
|
|
},
|
|
locales: {
|
|
primary: 'en',
|
|
active: ['en'],
|
|
forcePrefix: false,
|
|
showMenu: true
|
|
},
|
|
assets: {
|
|
logo: false,
|
|
favicon: false,
|
|
loginBg: false
|
|
},
|
|
editors: {
|
|
asciidoc: {
|
|
isActive: true,
|
|
config: {}
|
|
},
|
|
markdown: {
|
|
isActive: true,
|
|
config: {
|
|
allowHTML: true,
|
|
lineBreaks: true,
|
|
linkify: true,
|
|
multimdTable: true,
|
|
quotes: 'english',
|
|
tabWidth: 2,
|
|
typographer: false,
|
|
underline: true
|
|
}
|
|
},
|
|
wysiwyg: {
|
|
isActive: true,
|
|
config: {}
|
|
}
|
|
},
|
|
theme: {
|
|
dark: false,
|
|
codeBlocksTheme: 'github-dark',
|
|
colorPrimary: '#1976D2',
|
|
colorSecondary: '#02C39A',
|
|
colorAccent: '#FF9800',
|
|
colorHeader: '#000000',
|
|
colorSidebar: '#1976D2',
|
|
injectCSS: '',
|
|
injectHead: '',
|
|
injectBody: '',
|
|
contentWidth: 'full',
|
|
sidebarPosition: 'left',
|
|
tocPosition: 'right',
|
|
showPrintBtn: true,
|
|
baseFont: 'roboto',
|
|
contentFont: 'roboto'
|
|
},
|
|
uploads: {
|
|
conflictBehavior: 'overwrite'
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
export const sites = new Sites()
|