feat: page redirection + various fixes

scarlett
NGPixel 1 month ago
parent 81fc8db4f7
commit 8e6a35de98
No known key found for this signature in database

@ -419,15 +419,16 @@ An earlier iteration of 3.x used GraphQL/Apollo. **All of it is deprecated** —
server left in `backend/`, and `APOLLO_CLIENT` is not defined as a global, so any call still going
through it throws. `blocks/block-index/` also still imports a `tree.graphql`.
Four files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint
Three files under `frontend/src/` make live `APOLLO_CLIENT` calls, and each needs a REST endpoint
that does not exist yet, so the feature behind it is currently broken:
| File | Feature |
| ---- | ------- |
| `components/AuthLoginPanel.vue` | self-registration (the `register()` call only — passkey login and 2FA are REST now) |
| `pages/AdminGeneral.vue`, `pages/AdminNavigation.vue`, `pages/AdminUtilities.vue` | assorted admin actions |
| `pages/AdminNavigation.vue`, `pages/AdminUtilities.vue` | assorted admin actions |
When touching such a file, port it to the REST API (`API_CLIENT` + the matching `backend/api/` route)
rather than extending the GraphQL code. If the REST endpoint doesn't exist yet, add it under
`backend/api/` following the schema + permissions conventions above — `users/profile/editor-settings`
is a recent example of doing exactly that.
`backend/api/` following the schema + permissions conventions above — `sites/:siteId/images/:kind`,
which replaced the logo and favicon upload mutations in `AdminGeneral.vue`, is a recent example of
doing exactly that.

@ -70,7 +70,7 @@ async function routes(app: FastifyInstance) {
*/
schema: {
summary: 'Upload an asset',
description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.`,
description: `The body is the file itself, not a multipart form — send the bytes with their \`Content-Type\`. At most ${Math.round((WIKI.config.security?.uploadMaxFileSize ?? 10485760) / 1024 / 1024)} MB. The file name is sanitized, so the stored name in the response may differ from the one sent; the type served back later comes from that name's extension rather than from the request. Images get a thumbnail when the Sharp extension is installed.\n\nA file already at that name in that folder is settled by the site's upload conflict behavior: \`overwrite\` (the default) replaces it in place and answers with its existing ID, \`reject\` answers 409, and \`new\` stores the arrival as the next free \`name-1.ext\`. So the name and ID in the response are what to link to — never the ones that were sent. A page or a folder holding the name is answered 409 whichever behavior is set.`,
tags: ['Assets'],
consumes: ['*/*'],
params: {

@ -2,7 +2,7 @@ import { validate as uuidValidate } from 'uuid'
import type { FastifyInstance, FastifyRequest } from 'fastify'
import type { PageActor, PageInput } from '../models/pages.ts'
import { SEARCH_ORDER_BY, type SearchOrderBy } from '../models/search.ts'
import { generatePathHash } from '../helpers/common.ts'
import { generatePathHash, normalizePagePath } from '../helpers/common.ts'
import { limitAuthAttempts, limitRenders } from '../helpers/rateLimit.ts'
/** Comma-separated query lists, which is how the browser sends a multi-valued filter here. */
@ -386,8 +386,9 @@ async function routes(app: FastifyInstance) {
},
async (req, reply) => {
const actor = actorFrom(req)
// -> The stored path: no wrapping slashes, lowercase, and the site root is the `home` page
const path = req.query.path.trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase()
// -> The stored form of whatever the including page wrote, since that is what it is looked up
// by. The site root is the `home` page.
const path = normalizePagePath(req.query.path)
const page = await WIKI.models.pages.getPage({
siteId: req.params.siteId,
hash: generatePathHash(path || 'home'),

@ -56,11 +56,13 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
editor: {
type: 'string',
maxLength: 255,
description: 'Which editor authored the content, e.g. `markdown`.'
description:
'Which editor authored the content, e.g. `markdown`. `redirect` is a page with no body at all: it sends its reader elsewhere, is never searchable, and its content is the JSON below rather than a document.'
},
content: {
type: 'string',
description: 'The source, in whatever the editor writes.'
description:
'The source, in whatever the editor writes. For a `redirect` page, `{ "kind": "page" | "url", "target": string, "showInterstitial": boolean }` — a page target is a rooted path within this wiki, a URL target a complete http(s) address.'
},
render: {
type: 'string',
@ -179,7 +181,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
render: { type: 'string' },
content: {
type: 'string',
description: 'Only present when the request asked for it.'
description:
'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.'
},
allowComments: { type: 'boolean' },
allowContributions: { type: 'boolean' },

@ -40,9 +40,6 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'string'
}
},
pageCasing: {
type: 'boolean'
},
discoverable: {
type: 'boolean'
},
@ -98,10 +95,9 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
properties: {
conflictBehavior: {
type: 'string',
description:
'What an upload does about a file already at the name it wants: replace it in place, refuse the upload, or store the arrival as the next free `name-1.ext`.',
enum: ['overwrite', 'reject', 'new']
},
normalizeFilename: {
type: 'boolean'
}
}
},
@ -196,19 +192,15 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
},
assets: {
type: 'object',
description:
'Which images have been uploaded for this site. The images themselves are served from `/_site/<siteId>/<logo|favicon|loginBg>`, which falls back to the built-in default wherever the flag is false.',
properties: {
logo: {
type: 'boolean'
},
logoExt: {
type: 'string'
},
favicon: {
type: 'boolean'
},
faviconExt: {
type: 'string'
},
loginBg: {
type: 'boolean'
}

@ -1,7 +1,13 @@
import { validate as uuidValidate } from 'uuid'
import { CustomError } from '../helpers/common.ts'
import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts'
import { siteAssetKinds } from '../models/sites.ts'
import type { SiteAssetKind } from '../models/sites.ts'
import type { FastifyInstance } from 'fastify'
/** How large one of a site's own images may be uploaded, before it is re-encoded. */
const imageUploadLimit = 10 * 1024 * 1024
/**
* Site properties stored in the `config` JSONB column rather than as their own table column.
* Anything listed here is merged into the existing config on update.
@ -13,7 +19,6 @@ const SITE_CONFIG_KEYS = [
'contentLicense',
'footerExtra',
'pageExtensions',
'pageCasing',
'logoText',
'sitemap',
'discoverable',
@ -32,6 +37,17 @@ const SITE_CONFIG_KEYS = [
* Sites API Routes
*/
async function routes(app: FastifyInstance) {
// -> An image upload is the raw file rather than a multipart form: one file, no fields, and no
// dependency to add. Registered inside this plugin, so every other route keeps rejecting an
// image body outright.
app.addContentTypeParser(
[...imageMimeTypes, svgMimeType],
{ parseAs: 'buffer', bodyLimit: imageUploadLimit },
(req, body, done) => {
done(null, body)
}
)
app.get(
'/',
{
@ -247,7 +263,6 @@ async function routes(app: FastifyInstance) {
contentLicense?: string
footerExtra?: string
pageExtensions?: string[]
pageCasing?: boolean
logoText?: boolean
sitemap?: boolean
discoverable?: boolean
@ -321,9 +336,6 @@ async function routes(app: FastifyInstance) {
pattern: '^[a-z0-9]+$'
}
},
pageCasing: {
type: 'boolean'
},
logoText: {
type: 'boolean'
},
@ -475,6 +487,139 @@ async function routes(app: FastifyInstance) {
}
)
/**
* UPLOAD SITE IMAGE
*/
app.put<{ Params: { siteId: string; kind: SiteAssetKind } }>(
'/:siteId/images/:kind',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: "Replace one of a site's images",
description: `The body is the raw image, not a multipart form — send the file itself with its \`Content-Type\`. At most ${imageUploadLimit / 1024 / 1024} MB, and it must really be one of the accepted formats: the bytes are checked, not the declared type.\n\nA raster upload is re-encoded to the size and format the image is served at — 512x512 WebP for a logo, 180x180 PNG for a favicon, 1920x1080 WebP for a login background — when the Sharp extension is installed, and stored as uploaded when it is not. An SVG is always stored as uploaded.\n\nServed afterwards from \`/_site/<siteId>/<kind>\`, which falls back to the built-in default until something is uploaded.`,
tags: ['Sites'],
consumes: [...imageMimeTypes, svgMimeType],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
kind: {
type: 'string',
description: 'Which of the site images to replace.',
enum: [...siteAssetKinds]
}
},
required: ['siteId', 'kind']
},
response: {
200: {
description: 'Image uploaded successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
const data = req.body
if (!Buffer.isBuffer(data) || data.length < 1) {
throw new CustomError('siteImageEmpty', 'No image was sent.')
}
// -> The declared content type got the request this far; what the bytes actually are is what
// decides, since they are what gets stored and served back
if (!detectImageMime(data) && !detectSvg(data)) {
throw new CustomError(
'siteImageInvalidImage',
'Not an SVG, PNG, JPEG, WebP or GIF image, whatever the request said it was.'
)
}
await WIKI.models.sites.setAsset(req.params.siteId, req.params.kind, data)
return {
ok: true,
message: 'Image uploaded successfully.'
}
}
)
/**
* CLEAR SITE IMAGE
*/
app.delete<{ Params: { siteId: string; kind: SiteAssetKind } }>(
'/:siteId/images/:kind',
{
config: {
permissions: ['manage:sites']
},
schema: {
summary: "Remove one of a site's images",
description:
'Leaves the built-in default to be served in its place again. Succeeds even if there was no image to remove.',
tags: ['Sites'],
params: {
type: 'object',
properties: {
siteId: {
type: 'string',
format: 'uuid'
},
kind: {
type: 'string',
description: 'Which of the site images to remove.',
enum: [...siteAssetKinds]
}
},
required: ['siteId', 'kind']
},
response: {
200: {
description: 'Image cleared successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
}
}
}
}
}
},
async (req, reply) => {
const site = await WIKI.models.sites.getSiteById({ id: req.params.siteId })
if (!site) {
return reply.notFound('Site does not exist.')
}
await WIKI.models.sites.clearAsset(req.params.siteId, req.params.kind)
return {
ok: true,
message: 'Image cleared successfully.'
}
}
)
/**
* DELETE SITE
*/

@ -1,14 +1,41 @@
import { validate as uuidValidate } from 'uuid'
import { replyWithFile } from '../helpers/common.ts'
import { svgMimeType } from '../helpers/images.ts'
import crypto from 'node:crypto'
import path from 'node:path'
import type { SiteAssetKind } from '../models/sites.ts'
import type { FastifyInstance } from 'fastify'
/**
* What is served for each of a site's images while nobody has uploaded one. The keys are the names
* the images are addressed by, which are the asset kinds themselves.
*/
const SITE_ASSET_FALLBACKS: Record<SiteAssetKind, string> = {
logo: 'assets/_assets/logo-wikijs.svg',
favicon: 'assets/_assets/logo-wikijs.svg',
loginBg: 'assets/_assets/bg/login.jpg'
}
/**
* An uploaded site image changes whenever an administrator replaces it, and the URL never carries a
* version so it is always revalidated, and the ETag turns that into an empty 304 rather than a
* re-download.
*/
const SITE_ASSET_CACHE = 'public, no-cache'
/**
* An SVG is a document, not an image file: opened directly rather than through an `<img>`, a browser
* will run whatever scripts are in it, in this origin. Uploading one takes `manage:sites`, which
* already allows injecting markup into every page of the site but that is a reason to keep the
* blast radius of a stolen admin session small, not to ignore it. Nothing legitimate in a logo needs
* more than the markup itself, so the response allows nothing else.
*/
const SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
/**
* _site Routes
*/
async function routes(app: FastifyInstance) {
const siteAssetsPath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, 'assets')
app.get<{ Params: { siteId: string; resource: string } }>(
'/:siteId/:resource',
async (req, reply) => {
@ -23,41 +50,36 @@ async function routes(app: FastifyInstance) {
if (!site) {
return reply.notFound('Site not found')
}
switch (req.params.resource) {
case 'logo': {
if (site.config.assets.logo) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(
reply,
path.join(siteAssetsPath, `logo-${site.id}.${site.config.assets.logoExt}`)
)
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg'))
}
}
case 'favicon': {
if (site.config.assets.favicon) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(
reply,
path.join(siteAssetsPath, `favicon-${site.id}.${site.config.assets.faviconExt}`)
)
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/logo-wikijs.svg'))
}
}
case 'loginbg': {
if (site.config.assets.loginBg) {
// TODO: Fetch from db if not in disk cache
return replyWithFile(reply, path.join(siteAssetsPath, `loginbg-${site.id}.jpg`))
} else {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, 'assets/_assets/bg/login.jpg'))
}
}
default: {
return reply.badRequest('Invalid Site Resource')
}
const kind = req.params.resource as SiteAssetKind
const fallback = SITE_ASSET_FALLBACKS[kind]
if (!fallback) {
return reply.badRequest('Invalid Site Resource')
}
// -> The flag lives in the cached site config, so a site that has uploaded nothing — which is
// every site until an administrator says otherwise — never touches the database here
const asset = site.config.assets?.[kind]
? await WIKI.models.sites.getAsset(site.id, kind)
: null
if (!asset) {
return replyWithFile(reply, path.join(WIKI.ROOTPATH, fallback))
}
const etag = `"${crypto.createHash('sha1').update(asset.data).digest('hex')}"`
reply.header('ETag', etag)
reply.header('Cache-Control', SITE_ASSET_CACHE)
// -> The bytes were uploaded, so the browser must take the type at its word rather than looking
// for something more interesting in them
reply.header('X-Content-Type-Options', 'nosniff')
if (asset.mime === svgMimeType) {
reply.header('Content-Security-Policy', SVG_CSP)
}
if (req.headers['if-none-match'] === etag) {
return reply.code(304).send()
}
return reply.type(asset.mime).send(asset.data)
}
)
}

@ -0,0 +1,8 @@
CREATE TABLE "siteAssets" (
"siteId" uuid,
"kind" varchar(255),
"data" bytea NOT NULL,
CONSTRAINT "siteAssets_pkey" PRIMARY KEY("siteId","kind")
);
--> statement-breakpoint
ALTER TABLE "siteAssets" ADD CONSTRAINT "siteAssets_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");

File diff suppressed because it is too large Load Diff

@ -627,6 +627,23 @@ export const sites = pgTable('sites', {
createdAt: timestamp().notNull().defaultNow()
})
// -> The images an administrator uploads for a site — its logo, favicon and login background — one row
// per kind. Held in the database rather than under `dataPath`, which is a cache: an instance that
// comes back with an empty data directory must still look like itself. Whether a kind has been
// uploaded at all is mirrored in the site's `config.assets`, so serving a site that has uploaded
// nothing costs no query here.
export const siteAssets = pgTable(
'siteAssets',
{
siteId: uuid()
.notNull()
.references(() => sites.id),
kind: varchar({ length: 255 }).notNull(),
data: bytea().notNull()
},
(table) => [primaryKey({ columns: [table.siteId, table.kind] })]
)
// STORAGE -----------------------------
export const storage = pgTable(
'storage',

@ -82,6 +82,54 @@ export function encodeTreePath(str?: string | null): string {
return str?.toLowerCase()?.replaceAll('/', '.') || ''
}
/**
* Reduce a page path to the single form it is stored, addressed and looked up under.
*
* A path is a URL, and a URL that differs only in casing or in how a space was encoded is the same
* page as far as anyone reading the wiki is concerned so there is one spelling, and everything
* that takes a path from a human or from page content passes it through here first. Wrapping slashes
* go, runs of whitespace become a single hyphen, and what is left is lowercased.
*
* What it does not do is decide whether the result is *allowed*: the characters a path may contain
* are the page model's rule to enforce, on the normalized form.
*/
export function normalizePagePath(input?: string | null): string {
return (input ?? '')
.trim()
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replaceAll(/\s+/g, '-')
.toLowerCase()
}
/**
* Drop a site's page extension from the end of a URL path.
*
* A wiki's pages are addressed without one `/foo/bar`, not `/foo/bar.md` but the file the page
* was written as keeps turning up in links: an export, a repository mirror, a migration from a system
* that served files. So a site lists the extensions its content is written in, and a path ending in
* one of them means the page underneath it.
*
* Only the last segment is considered, and only when there is a name in front of the dot: `/.md` and
* `/docs.md/thing` address nothing.
*
* @param extensions Lowercase, without the dot, as the site config stores them
* @returns The path without the extension, or null if it does not end in one of them
*/
export function stripPageExtension(urlPath: string, extensions?: string[] | null): string | null {
if (!extensions || extensions.length < 1) {
return null
}
const dot = urlPath.lastIndexOf('.')
if (dot < 1 || urlPath[dot - 1] === '/' || urlPath.lastIndexOf('/') > dot) {
return null
}
if (!extensions.includes(urlPath.slice(dot + 1).toLowerCase())) {
return null
}
return urlPath.slice(0, dot)
}
/**
* Generate SHA-1 Hash of a string
*

@ -10,6 +10,25 @@ export const imageMimeTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/g
export type ImageMimeType = (typeof imageMimeTypes)[number]
/**
* SVG, which is markup rather than an image format and so is handled apart from the raster ones
* everywhere: it is recognized by reading it, it cannot be resized or re-encoded, and serving one
* back means serving a document a browser will happily execute scripts from.
*/
export const svgMimeType = 'image/svg+xml'
/**
* Recognize SVG markup.
*
* There is no magic number to match: an SVG may open with a byte order mark, an XML declaration, a
* doctype or comments before the root element ever appears. So the start of the file is read as text
* and the root element looked for enough to tell an SVG from a file claiming to be one, which is
* all this decides.
*/
export function detectSvg(data: Buffer): boolean {
return /<svg[\s>]/i.test(data.subarray(0, 1024).toString('utf8'))
}
/**
* Recognize an image from its leading bytes.
*
@ -77,6 +96,63 @@ export async function resizeImageToSquareJpeg(data: Buffer, size: number): Promi
}
}
/** How an uploaded image is brought down to the size and format it will be served at. */
export type ImageNormalization = {
width: number
height: number
/**
* `cover` crops to the target aspect ratio, for an image whose frame is fixed a favicon, a
* background. `inside` fits within the box instead, for one whose own proportions matter, such as
* a logo that may be any shape.
*/
fit: 'cover' | 'inside'
/** `webp` for anything displayed by the app itself; `png` where the widest support is worth the
* bytes, as it is for a favicon. Both keep transparency, which a logo usually depends on. */
format: 'webp' | 'png'
}
/**
* Re-encode an image to the given size and format, using the Sharp extension.
*
* Never enlarges: upscaling a small upload would cost bytes to look worse. So the result is at most
* the requested size, and an image already smaller than the box is only re-encoded.
*
* @returns The re-encoded image, or null if Sharp is not usable on this system
*/
export async function normalizeImage(
data: Buffer,
{ width, height, fit, format }: ImageNormalization
): Promise<Buffer | null> {
const definition = WIKI.models.extensions.getDefinition('sharp')
if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) {
return null
}
const specifier = 'sharp'
// -> Loading Sharp and running it are kept apart, as they are for a thumbnail: the upload may simply
// be an image Sharp cannot read, which must not be recorded as Sharp itself being broken
let sharp: any
try {
;({ default: sharp } = await import(specifier))
} catch (err: any) {
WIKI.models.extensions.noteLoadFailure(specifier)
WIKI.logger.warn(`Could not load Sharp to re-encode an image: ${err.message}`)
return null
}
try {
const resized = sharp(data).resize(width, height, {
fit,
position: 'centre',
withoutEnlargement: true
})
return await (
format === 'png' ? resized.png({ compressionLevel: 9 }) : resized.webp({ quality: 80 })
).toBuffer()
} catch (err: any) {
WIKI.logger.warn(`Could not re-encode an uploaded image: ${err.message}`)
return null
}
}
/**
* Shrink an image to a WebP thumbnail, using the Sharp extension.
*

@ -34,10 +34,30 @@ import configSvc from './core/config.ts'
import dbManager from './core/db.ts'
import logger from './core/logger.ts'
import scheduler from './core/scheduler.ts'
import { stripPageExtension } from './helpers/common.ts'
import { corsOrigin, parseCspDirectives } from './helpers/security.ts'
const nanoid = customAlphabet('1234567890abcdef', 10)
/**
* Files a browser or a crawler asks for at the root by convention, rather than because the wiki has a
* page there. Kept out of the page URL rules below `txt` is a page extension on a default site, and
* answering `/robots.txt` with a redirect to `/robots` would be answering the wrong question.
*/
const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'])
/**
* Whether a URL addresses the page tree rather than the server itself.
*
* Everything the server mounts sits under a leading-underscore segment `/_api`, `/_assets`,
* `/_files`, and the rest registered in `initHTTPServer` which is what makes the distinction a
* prefix test rather than a list to keep in step with the routes.
*/
function isPageUrl(urlPath: string): boolean {
const firstSegment = urlPath.split('/')[1] ?? ''
return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
}
if (!semver.satisfies(process.version, '>=26')) {
console.error('ERROR: Node.js 26.x or later required!')
process.exit(1)
@ -545,11 +565,34 @@ async function initHTTPServer() {
app.addHook('onRequest', (req, reply, done) => {
const [urlPath, urlQuery] = req.raw.url!.split('?')
if (urlPath!.length > 1 && urlPath!.endsWith('/')) {
const newPath = urlPath!.slice(0, -1)
reply.redirect(urlQuery ? `${newPath}?${urlQuery}` : newPath, 301)
const withQuery = (newPath: string) => (urlQuery ? `${newPath}?${urlQuery}` : newPath)
const trimmed = urlPath!.length > 1 && urlPath!.endsWith('/') ? urlPath!.slice(0, -1) : urlPath!
if (isPageUrl(trimmed)) {
// -> Straight off the site caches rather than through the model: this runs on every request, and
// both lookups are the ones `getSiteByHostname` would do, minus its optional reload
const siteId = WIKI.sitesMappings[req.hostname] || WIKI.sitesMappings['*']
const withoutExtension = stripPageExtension(
trimmed,
WIKI.sites[siteId]?.config?.pageExtensions
)
if (withoutExtension) {
// -> Answers a trailing slash as well, rather than sending the client back for a second
// round trip to be told about the extension.
//
// Not a 301: which extensions resolve this way is a setting, and a browser that cached a
// permanent redirect would go on applying it after an administrator had changed it
reply.redirect(withQuery(withoutExtension), 302)
return
}
}
if (trimmed !== urlPath) {
reply.redirect(withQuery(trimmed), 301)
return
}
done()
})

@ -315,21 +315,26 @@
"admin.general.displaySiteTitle": "Display Site Title",
"admin.general.displaySiteTitleHint": "Should the site title be displayed next to the logo? If your logo isn't square and contain your brand name, turn this option off.",
"admin.general.favicon": "Favicon",
"admin.general.faviconHint": "Favicon image file, in SVG, PNG, JPG, WEBP or GIF format. Must be a square image.",
"admin.general.faviconClearFailed": "Failed to clear the site favicon.",
"admin.general.faviconClearSuccess": "Site favicon cleared successfully.",
"admin.general.faviconHint": "Favicon image file, in SVG, PNG, JPG, WEBP or GIF format. A square image works best, as it is cropped to a square.",
"admin.general.faviconUploadFailed": "Failed to upload the site favicon.",
"admin.general.faviconUploadSuccess": "Site Favicon uploaded successfully.",
"admin.general.features": "Features",
"admin.general.footerCopyright": "Footer / Copyright",
"admin.general.footerExtra": "Additional Footer Text",
"admin.general.footerExtraHint": "Optionally add more content to the footer, such as additional copyright terms or mandatory regulatory info.",
"admin.general.general": "General",
"admin.general.imageUploadInvalidType": "Only SVG, PNG, JPG, WEBP and GIF images can be used.",
"admin.general.logo": "Logo",
"admin.general.logoClearFailed": "Failed to clear the site logo.",
"admin.general.logoClearSuccess": "Site logo cleared successfully.",
"admin.general.logoUpl": "Site Logo",
"admin.general.logoUplHint": "Logo image file, in SVG, PNG, JPG, WEBP or GIF format.",
"admin.general.logoUploadFailed": "Failed to upload the site logo.",
"admin.general.logoUploadSuccess": "Site logo uploaded successfully.",
"admin.general.pageCasing": "Case Sensitive Paths",
"admin.general.pageCasingHint": "Treat paths with different casing as distinct pages.",
"admin.general.pageExtensions": "Page Extensions",
"admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that will be treated as pages. For example, adding md will treat /foobar.md the same as /foobar.",
"admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar.",
"admin.general.ratingsOff": "Off",
"admin.general.ratingsStars": "Stars",
"admin.general.ratingsThumbs": "Thumbs",
@ -361,13 +366,11 @@
"admin.general.title": "General",
"admin.general.uploadClear": "Clear",
"admin.general.uploadConflictBehavior": "Upload Conflict Behavior",
"admin.general.uploadConflictBehaviorHint": "How should uploads for a file that already exists be handled?",
"admin.general.uploadConflictBehaviorNew": "Append Time to Filename",
"admin.general.uploadConflictBehaviorHint": "How should uploads for a file that already exists be handled? Overwriting replaces the file where it is, so pages already using it show the new version.",
"admin.general.uploadConflictBehaviorNew": "Keep Both (Append Number)",
"admin.general.uploadConflictBehaviorOverwrite": "Overwrite",
"admin.general.uploadConflictBehaviorReject": "Reject",
"admin.general.uploadLogo": "Upload Logo",
"admin.general.uploadNormalizeFilename": "Normalize Filenames",
"admin.general.uploadNormalizeFilenameHint": "Automatically transform filenames to a standard URL-friendly format.",
"admin.general.uploadSizeHint": "An image of {size} pixels is recommended for best results.",
"admin.general.uploadTypesHint": "{typeList} or {lastType} files only",
"admin.general.uploads": "Uploads",
@ -522,9 +525,12 @@
"admin.locale.title": "Locale",
"admin.logging.title": "Logging",
"admin.login.background": "Background Image",
"admin.login.backgroundHint": "Specify an image to use as the login background. PNG and JPG are supported, 1920x1080 recommended. Leave empty for default.",
"admin.login.backgroundHint": "Specify an image to use as the login background. SVG, PNG, JPG, WEBP and GIF are supported, 1920x1080 recommended. Clear it to use the default.",
"admin.login.bgClearFailed": "Failed to clear the login background image.",
"admin.login.bgClearSuccess": "Login background image cleared successfully.",
"admin.login.bgUploadFailed": "Failed to upload the login background image.",
"admin.login.bgUploadInvalidType": "Only SVG, PNG, JPG, WEBP and GIF images can be used as a login background.",
"admin.login.bgUploadSuccess": "Login background image uploaded successfully.",
"admin.login.bgUploadUnavailable": "Uploading a background image is not implemented yet.",
"admin.login.bypassScreen": "Bypass Login Screen",
"admin.login.bypassScreenHint": "Should the user be redirected automatically to the first authentication provider. Has no effect if the first provider is a username/password provider type.",
"admin.login.bypassUnauthorized": "Bypass Unauthorized Screen",
@ -1612,6 +1618,14 @@
"common.password.poor": "Poor",
"common.password.strong": "Strong",
"common.password.weak": "Weak",
"common.redirect.broken": "This redirection has no target.",
"common.redirect.brokenHint": "Edit this page to choose where it should send readers.",
"common.redirect.chain": "These redirections lead in a circle.",
"common.redirect.follow": "Follow Redirection",
"common.redirect.goNow": "Go Now",
"common.redirect.held": "This page redirects elsewhere.",
"common.redirect.loop": "This redirection points at itself.",
"common.redirect.redirectingTo": "Redirecting to {target}...",
"common.sidebar.browse": "Browse",
"common.sidebar.currentDirectory": "Current Directory",
"common.sidebar.mainMenu": "Main Menu",
@ -1853,6 +1867,19 @@
"editor.reasonForChange.reasonMissing": "A reason is missing.",
"editor.reasonForChange.required": "You must provide a reason for this change. Enter a small description of what changed.",
"editor.reasonForChange.title": "Reason For Change",
"editor.redirect.choose": "Choose...",
"editor.redirect.noTargetSelected": "No target selected yet.",
"editor.redirect.pageTitle": "Redirect Title",
"editor.redirect.pageTitleHint": "The name this page appears under in navigation and in the file manager.",
"editor.redirect.pickerTitle": "Select Redirection Target",
"editor.redirect.showInterstitial": "Show Interstitial",
"editor.redirect.showInterstitialHint": "Show a short notice saying where the reader is going before taking them there. Off sends them straight on.",
"editor.redirect.summaryDirect": "Readers arriving at this page are sent to {target} right away.",
"editor.redirect.summaryIncomplete": "This redirection has no target yet, and cannot be saved until it does.",
"editor.redirect.summaryInterstitial": "Readers arriving at this page are told they are being sent to {target}, then taken there a few seconds later.",
"editor.redirect.target": "Target",
"editor.redirect.targetHint": "Where readers arriving at this page are sent — a page of this wiki, or any URL.",
"editor.redirect.title": "Redirection",
"editor.renderFailed": "The preview could not be rendered. The last successful render is kept.",
"editor.renderPreview": "Render Preview",
"editor.save.createSuccess": "Page created successfully.",
@ -1994,6 +2021,7 @@
"fileman.pptxFileType": "Microsoft Powerpoint Presentation",
"fileman.psdFileType": "Adobe Photoshop Document",
"fileman.rarFileType": "RAR Archive",
"fileman.redirectPageType": "Redirection",
"fileman.renameAssetInvalid": "Asset name is invalid.",
"fileman.renameAssetSuccess": "Asset renamed successfully",
"fileman.renameFolderInvalidData": "One or more fields are invalid.",

@ -44,6 +44,24 @@ export const INLINE_EXTS = new Set(['png', 'apng', 'jpg', 'jpeg', 'gif', 'bmp',
/** What an asset is, for the sake of grouping and filtering. Mirrors the `assetKind` schema enum. */
export type AssetKind = 'document' | 'image' | 'other'
/**
* What an upload does about a file already sitting at the name it wants, per the site's
* `uploads.conflictBehavior` setting.
*
* - `overwrite` replaces the file where it is: same ID, same path, so every page pointing at it now
* shows the new contents. This is the default, and the one that makes re-uploading a corrected file
* do what the uploader meant.
* - `reject` refuses the upload and says what is in the way, for a wiki where a file's contents are
* expected to be stable once published.
* - `new` keeps both, the arrival taking the next free `name-1.ext`.
*
* Whichever is chosen, only an *asset* can be replaced: a page or a folder already holding the name
* is reported rather than written over.
*/
export type UploadConflictBehavior = 'overwrite' | 'reject' | 'new'
const UPLOAD_CONFLICT_BEHAVIORS = new Set<UploadConflictBehavior>(['overwrite', 'reject', 'new'])
/** Extensions that count as a document rather than "other". */
const DOCUMENT_EXTS = new Set([
'csv',
@ -93,6 +111,9 @@ export interface AssetAtPath extends Asset {
* Any directory part is dropped the folder comes from the request, never from the name and what
* is left is lowercased down to the characters that survive a URL untouched, which is the same bar
* folder path names are held to.
*
* Applied to every upload, with nothing to turn it off: a stored name is a URL, and a path is looked
* up lowercased, so a name that skipped this would be one the site could not serve back.
*/
export function sanitizeFileName(input: string): string {
const base = path.basename(input.trim().replaceAll('\\', '/'))
@ -165,9 +186,24 @@ class Assets {
/** Whether a sweep is running, so that a burst of writes queues no more than one. */
sweeping = false
/**
* What this site does about an upload landing on a name that is taken.
*
* Read per upload rather than held anywhere, so that changing it in the admin area applies to the
* next file rather than to the next restart. Anything unrecognized is treated as the default.
*/
conflictBehaviorFor(siteId: string): UploadConflictBehavior {
const configured = WIKI.sites[siteId]?.config?.uploads?.conflictBehavior
return UPLOAD_CONFLICT_BEHAVIORS.has(configured) ? configured : 'overwrite'
}
/**
* Store an uploaded file.
*
* A file already at this name is settled per the site's conflict behavior see
* `UploadConflictBehavior`. An overwrite returns the existing asset's ID, so a caller that means to
* link to what it just uploaded must read the returned name and ID rather than assume its own.
*
* @param folderId UUID of the folder to upload into. The site root when absent.
* @param fileName What to call it. Sanitized, so what comes back may differ from what went in.
* @param data The file itself.
@ -204,6 +240,50 @@ class Assets {
? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height)
: null
// -> What is already at this name, if anything, and what the site says to do about it. Asked
// before any row is touched, since two of the three answers write nothing new at all.
const behavior = this.conflictBehaviorFor(siteId)
const occupant =
behavior === 'new'
? null
: await WIKI.models.tree.getEntryAt({
siteId,
locale,
parentId: folderId,
fileName: safeName
})
if (occupant) {
if (occupant.type !== 'asset') {
// -> Neither replacing nor renaming is what an administrator asked for here: a page or a
// folder owns this name, and only its owner can give it up
throw new CustomError(
'assetNameTakenByEntry',
`A ${occupant.type} with this name already exists here.`,
409
)
}
if (behavior === 'reject') {
throw new CustomError(
'assetAlreadyExists',
'A file with this name already exists here.',
409
)
}
return this.replace({
id: occupant.id,
siteId,
folderPath: decodeTreePath(occupant.folderPath ?? '') ?? '',
fileName: occupant.fileName,
title: occupant.title,
fileExt,
kind,
mimeType: resolvedMime,
data,
preview,
authorId
})
}
// -> The tree row goes in first: it owns the name, and it is what settles a collision with
// something already in the folder before any bytes are written. What comes back is the name
// that was actually free, which is not always the one asked for.
@ -264,6 +344,98 @@ class Assets {
}
}
/**
* Replace an existing asset's contents in place, for an upload that landed on it under the
* `overwrite` conflict behavior.
*
* The asset keeps its ID, its name and its place in the tree, so every page and every link already
* pointing at the file goes on working and now resolves to the new bytes. What changes is what the
* file *is* its contents, size, type and thumbnail plus who put them there.
*
* The name it keeps is the stored one, which is why the extension and type are the incoming file's:
* the two only differ when a browser sent `Photo.PNG` for what is stored as `photo.png`, and the
* sanitized name is what both agree on.
*/
private async replace({
id,
siteId,
folderPath,
fileName,
title,
fileExt,
kind,
mimeType,
data,
preview,
authorId
}: {
id: string
siteId: string
folderPath: string
fileName: string
title: string
fileExt: string
kind: AssetKind
mimeType: string
data: Buffer
preview: Buffer | null
authorId: string
}): Promise<Asset> {
await WIKI.db
.update(assetsTable)
.set({
fileExt,
kind,
mimeType,
fileSize: data.length,
data,
preview,
authorId,
updatedAt: sql`now()`
})
.where(eq(assetsTable.id, id))
// -> The tree carries its own copy of these, and it is what a folder listing reads
await WIKI.db
.update(treeTable)
.set({ meta: { fileSize: data.length, fileExt, mimeType }, updatedAt: sql`now()` })
.where(eq(treeTable.id, id))
// -> The path resolves to the same asset as before, but to different metadata: the ETag is the
// modification time, so a reader holding the old file has to be told to fetch it again. The
// cached bytes are keyed by that same time and are unreachable from here on, but are dropped
// rather than left for the sweep, since the file they hold is gone for good.
this.forgetPath(siteId, folderPath, fileName)
await this.dropCachedContent([id])
WIKI.models.hooks.emit('asset:edit', {
id,
fileName,
folderPath,
siteId,
authorId,
metadata: { fileSize: data.length, mimeType, kind }
})
const updated = await this.getAsset(siteId, id)
// -> Only if the row vanished between the update and the read, which means someone deleted the
// file mid-upload. Answering with what was written beats failing a request that did land.
return (
updated ?? {
id,
fileName,
fileExt,
kind,
mimeType,
fileSize: data.length,
folderPath,
title,
hasPreview: Boolean(preview),
createdAt: new Date(),
updatedAt: new Date()
}
)
}
/**
* An asset's metadata, without its bytes. Null if there is no such asset on this site.
*/

@ -40,6 +40,7 @@ export const EMITTED_EVENTS: HookEvent[] = [
'page:rename',
'page:delete',
'asset:upload',
'asset:edit',
'asset:rename',
'asset:delete',
'user:join',

@ -1,6 +1,11 @@
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import { CustomError, generatePathHash, timingSafeCompare } from '../helpers/common.ts'
import {
CustomError,
generatePathHash,
normalizePagePath,
timingSafeCompare
} from '../helpers/common.ts'
import type { RenderPermissions, TocNode } from './rendering.ts'
import type { DeletedEntry } from './tree.ts'
@ -8,9 +13,20 @@ import type { DeletedEntry } from './tree.ts'
const EDITOR_CONTENT_TYPES: Record<string, string> = {
markdown: 'markdown',
asciidoc: 'asciidoc',
wysiwyg: 'html'
wysiwyg: 'html',
redirect: 'redirect'
}
/**
* The editor whose pages send their reader somewhere else.
*
* A redirection is an ordinary page it has a path, a title, an icon and a place in the tree, and is
* browsable like any other with nothing to read: no body, no render, and therefore nothing for the
* search index to hold. What an author fills in is where it points, and that is what its content
* column carries. See `normalizeRedirectContent`.
*/
const REDIRECT_EDITOR = 'redirect'
/** A page path is what ends up in a URL, so it is held to what reads and routes cleanly. */
const rePagePath = /^[a-zA-Z0-9-_/]*$/
const reAlias = /^[a-zA-Z0-9-_]*$/
@ -55,6 +71,10 @@ export interface Page {
tags: string[]
toc: TocNode[]
render: string
/**
* The source. Present when the request asked for it, and always for a redirection see `toPage`,
* and `RedirectContent` for what a redirection's holds.
*/
content?: string
allowComments: boolean
allowContributions: boolean
@ -122,10 +142,13 @@ function hasPermission(actor: PageActor, permission: string): boolean {
}
/**
* Strip a path down to the form that gets stored: no wrapping slashes, lowercase.
* Normalize a path to the form that gets stored, and refuse it if what is left is not addressable.
*
* Casing and spaces are corrected rather than rejected `My Page` is a path someone meant, and it
* means `my-page`. Anything else outside the allowed characters is not something to guess at.
*/
function normalizePath(input: string): string {
const path = (input ?? '').trim().replace(/^\/+/, '').replace(/\/+$/, '').toLowerCase()
const path = normalizePagePath(input)
if (!rePagePath.test(path)) {
throw new CustomError(
'pageInvalidPath',
@ -135,6 +158,66 @@ function normalizePath(input: string): string {
return path
}
/**
* Where a redirection points, as its content column holds it.
*
* `kind` is stored rather than sniffed off the target, because it is the question the author actually
* answered: a page of this wiki, or somewhere else. The two are not reliably told apart afterwards
* `/help` is a page here and a perfectly good relative URL elsewhere and the editor has to open on
* the choice that was made rather than on a guess about it.
*/
export interface RedirectContent {
kind: 'page' | 'url'
/** A rooted path within this wiki, or an absolute `http(s)` URL. */
target: string
/** Whether the reader is told where they are going before being taken there. */
showInterstitial: boolean
}
/**
* Read a redirection's target back out of what the editor sent, and refuse anything that would not
* send a reader anywhere.
*
* Re-serialized rather than stored as it arrived, so that the column holds one canonical spelling: a
* save that changes nothing then reports no change, and the history rows say what they mean.
*
* A URL target is held to `http`/`https` deliberately. This value ends up in a `location` assignment,
* so any other scheme is either useless (`mailto:` in a redirect that nobody chose to follow) or an
* invitation (`javascript:`) and a redirection is followed without the reader clicking anything.
*/
function normalizeRedirectContent(content: string | undefined): string {
let parsed: any
try {
parsed = JSON.parse(content ?? '')
} catch {
throw new CustomError('pageRedirectInvalid', 'A redirection needs a target.')
}
const kind = parsed?.kind === 'url' ? 'url' : 'page'
const target = typeof parsed?.target === 'string' ? parsed.target.trim() : ''
if (target.length < 1) {
throw new CustomError('pageRedirectMissingTarget', 'A redirection needs a target.')
}
if (kind === 'url') {
if (!/^https?:\/\/\S/i.test(target)) {
throw new CustomError(
'pageRedirectInvalidUrl',
'A redirection to a URL must be a complete http:// or https:// address.'
)
}
} else if (!target.startsWith('/') || target.startsWith('//')) {
throw new CustomError(
'pageRedirectInvalidPath',
'A redirection to a page of this wiki must be a path starting with a slash.'
)
}
const redirect: RedirectContent = {
kind,
target,
showInterstitial: parsed?.showInterstitial === true
}
return JSON.stringify(redirect)
}
/**
* Pages model
*
@ -156,6 +239,11 @@ class Pages {
* looking at the lock screen is told what page they are being asked for a password to.
* @param withPassword Include the page's own password. Only for a requester who may edit the page,
* which is the one that has to be able to read it back and save it again.
* @param withContent Include the source. A redirection's comes back either way: its content is not
* a body somebody wrote, it is where the page sends its reader which every
* reader is about to be shown by being taken there. Withholding it would leave
* the page view unable to do the one thing the page is for, and the page view
* does not ask for content.
*/
private toPage(
row: any,
@ -189,7 +277,9 @@ class Pages {
tags: row.tags ?? [],
toc: locked ? [] : (row.toc ?? []),
render: locked ? '' : (row.render ?? ''),
...(withContent && !locked ? { content: row.content ?? '' } : {}),
...((withContent || row.editor === REDIRECT_EDITOR) && !locked
? { content: row.content ?? '' }
: {}),
allowComments: config.allowComments ?? true,
allowContributions: config.allowContributions ?? true,
allowRatings: config.allowRatings ?? true,
@ -375,10 +465,14 @@ class Pages {
if (title.length < 1) {
throw new CustomError('pageTitleMissing', 'A page needs a title.')
}
if (!input.content || input.content.trim().length < 1) {
const editor = input.editor || 'markdown'
const isRedirect = editor === REDIRECT_EDITOR
// -> A redirection has no body to be empty: what it holds instead is where it points, and that has
// its own rules about being filled in
const content = isRedirect ? normalizeRedirectContent(input.content) : input.content
if (!isRedirect && (!content || content.trim().length < 1)) {
throw new CustomError('pageEmptyContent', 'A page cannot be empty.')
}
const editor = input.editor || 'markdown'
const hash = generatePathHash(path)
const duplicate = await WIKI.db
@ -411,14 +505,16 @@ class Pages {
creatorId: actor.id,
ownerId: actor.id,
config: this.buildConfig(input, siteId),
content: input.content,
content,
contentType: EDITOR_CONTENT_TYPES[editor] ?? 'text',
description: input.description ?? '',
editor,
hash,
icon: input.icon ?? '',
isBrowsable: input.isBrowsable ?? true,
isSearchable: input.isSearchable ?? true,
// -> A redirection has nothing to find: a result for it would be a result whose page is a
// doorway to the page the reader actually wanted, which is the one search should offer
isSearchable: isRedirect ? false : (input.isSearchable ?? true),
locale,
password: input.password || null,
path,
@ -498,6 +594,9 @@ class Pages {
const values: Record<string, any> = { updatedAt: sql`now()` }
let treeTitle: string | null = null
// -> Which editor authored a page is not something a save may change, so the row is the authority
// on whether this is a redirection
const isRedirect = existing.editor === REDIRECT_EDITOR
if (patch.title !== undefined) {
const title = patch.title.trim()
@ -517,7 +616,7 @@ class Pages {
values.alias = await this.validateAlias(siteId, patch.alias, id)
}
if (patch.content !== undefined) {
values.content = patch.content
values.content = isRedirect ? normalizeRedirectContent(patch.content) : patch.content
}
if (patch.publishState !== undefined) {
if (
@ -542,7 +641,8 @@ class Pages {
values.isBrowsable = patch.isBrowsable
}
if (patch.isSearchable !== undefined) {
values.isSearchable = patch.isSearchable
// -> Never for a redirection; see the same call in `createPage`
values.isSearchable = isRedirect ? false : patch.isSearchable
}
if (patch.password !== undefined) {
values.password = patch.password || null

@ -2,12 +2,39 @@ 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 { eq } from 'drizzle-orm'
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
*/
@ -73,7 +100,6 @@ class Sites {
contentLicense: '',
footerExtra: '',
pageExtensions: ['md', 'html', 'txt'],
pageCasing: true,
discoverable: false,
defaults: {
tocDepth: {
@ -116,9 +142,7 @@ class Sites {
},
assets: {
logo: false,
logoExt: 'svg',
favicon: false,
faviconExt: 'svg',
loginBg: false
},
theme: {
@ -163,8 +187,7 @@ class Sites {
}
},
uploads: {
conflictBehavior: 'overwrite',
normalizeFilename: true
conflictBehavior: 'overwrite'
}
},
config
@ -232,12 +255,75 @@ class Sites {
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 and storage rows are registration metadata derived from disk, 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.
// -> 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) {
@ -266,7 +352,6 @@ class Sites {
contentLicense: '',
footerExtra: '',
pageExtensions: ['md', 'html', 'txt'],
pageCasing: true,
discoverable: false,
defaults: {
tocDepth: {
@ -307,9 +392,7 @@ class Sites {
},
assets: {
logo: false,
logoExt: 'svg',
favicon: false,
faviconExt: 'svg',
loginBg: false
},
editors: {
@ -354,8 +437,7 @@ class Sites {
contentFont: 'roboto'
},
uploads: {
conflictBehavior: 'overwrite',
normalizeFilename: true
conflictBehavior: 'overwrite'
}
}
})

@ -6,7 +6,8 @@ import {
decodeTreePath,
encodeTreePath,
generateHash,
generatePathHash
generatePathHash,
normalizePagePath
} from '../helpers/common.ts'
/** What a tree entry can be. Mirrors the `treeType` enum in the schema. */
@ -575,6 +576,56 @@ class Tree {
return (results[0] as TreeRow) ?? null
}
/**
* Whatever already sits at a name inside a folder, or null if the name is free.
*
* The question an upload has to ask before it writes anything, since what is there decides whether
* the file replaces it, is refused, or takes the next free name. A folder that does not exist holds
* nothing, so an unresolvable destination answers null rather than raising: the caller is about to
* create it.
*
* @param parentId UUID of the folder to look in. Takes precedence over `parentPath`; the site root
* when both are absent.
*/
async getEntryAt({
siteId,
locale,
parentId,
parentPath,
fileName
}: {
siteId: string
locale: string
parentId?: string | null
parentPath?: string | null
fileName: string
}): Promise<TreeRow | null> {
let path = ''
if (parentId || parentPath) {
let folder: TreeRow
try {
folder = await this.getFolder({ id: parentId, path: parentPath, locale, siteId })
} catch {
return null
}
path = childPathOf(folder)
}
const results = await WIKI.db
.select()
.from(treeTable)
.where(
and(
eq(treeTable.siteId, siteId),
eq(treeTable.locale, locale),
eq(treeTable.folderPath, path),
eq(treeTable.fileName, fileName)
)
)
.limit(1)
return (results[0] as TreeRow) ?? null
}
/**
* Resolve a folder, either by ID or by path.
*
@ -637,7 +688,8 @@ class Tree {
*
* @param parentId UUID of the folder to create it in. Takes precedence over `parentPath`.
* @param parentPath Slash-separated path of the folder to create it in. The root when both are absent.
* @param pathName The folder's own path segment, lowercase and URL friendly.
* @param pathName The folder's own path segment. Normalized the way a page path is, so what the
* folder ends up called may differ from what was asked for.
*/
async createFolder({
parentId,
@ -654,7 +706,10 @@ class Tree {
locale: string
siteId: string
}): Promise<TreeRow> {
if (!rePathName.test(pathName)) {
// -> A folder name is a segment of every page path under it, so it is normalized the same way a
// page path is before it is held to what a segment may contain
const name = normalizePagePath(pathName)
if (!rePathName.test(name)) {
throw new CustomError(
'treeInvalidPath',
'A folder path name may only contain lowercase alphanumeric and hyphen characters.'
@ -685,7 +740,7 @@ class Tree {
eq(treeTable.siteId, siteId),
eq(treeTable.locale, effectiveLocale),
eq(treeTable.folderPath, path),
eq(treeTable.fileName, pathName),
eq(treeTable.fileName, name),
eq(treeTable.type, 'folder')
)
)
@ -753,12 +808,12 @@ class Tree {
}
}
const fullPath = path ? `${decodeTreePath(path)}/${pathName}` : pathName
const fullPath = path ? `${decodeTreePath(path)}/${name}` : name
const inserted = await WIKI.db
.insert(treeTable)
.values({
folderPath: path,
fileName: pathName,
fileName: name,
type: 'folder',
title,
hash: generateHash(fullPath),
@ -777,8 +832,8 @@ class Tree {
/**
* Rename a folder, moving everything under it along with it.
*
* @param pathName The new path segment. Unchanged from the current one when only the title differs,
* which leaves every descendant's path untouched.
* @param pathName The new path segment, normalized as on the way in. Unchanged from the current
* one when only the title differs, which leaves every descendant's path untouched.
*/
async renameFolder({
folderId,
@ -793,7 +848,10 @@ class Tree {
if (!folder) {
throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404)
}
if (!rePathName.test(pathName)) {
// -> Normalized as it is on the way in, since this renames the segment every page path under the
// folder is built from
const name = normalizePagePath(pathName)
if (!rePathName.test(name)) {
throw new CustomError(
'treeInvalidPath',
'A folder path name may only contain lowercase alphanumeric and hyphen characters.'
@ -803,7 +861,7 @@ class Tree {
throw new CustomError('treeInvalidTitle', 'The folder title contains invalid characters.')
}
if (pathName === folder.fileName) {
if (name === folder.fileName) {
const updated = await WIKI.db
.update(treeTable)
.set({ title, updatedAt: sql`now()` })
@ -821,7 +879,7 @@ class Tree {
eq(treeTable.siteId, folder.siteId),
eq(treeTable.locale, folder.locale),
eq(treeTable.folderPath, folder.folderPath ?? ''),
eq(treeTable.fileName, pathName),
eq(treeTable.fileName, name),
eq(treeTable.type, 'folder')
)
)
@ -835,7 +893,7 @@ class Tree {
}
const oldPath = childPathOf(folder)
const newPath = folder.folderPath ? `${folder.folderPath}.${pathName}` : pathName
const newPath = folder.folderPath ? `${folder.folderPath}.${name}` : name
WIKI.logger.debug(`Renaming folder ${folder.id} from ${oldPath} to ${newPath}...`)
@ -854,12 +912,10 @@ class Tree {
and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${oldPath}::ltree`)
)
const fullPath = folder.folderPath
? `${decodeTreePath(folder.folderPath)}/${pathName}`
: pathName
const fullPath = folder.folderPath ? `${decodeTreePath(folder.folderPath)}/${name}` : name
const updated = await WIKI.db
.update(treeTable)
.set({ fileName: pathName, title, hash: generateHash(fullPath), updatedAt: sql`now()` })
.set({ fileName: name, title, hash: generateHash(fullPath), updatedAt: sql`now()` })
.where(eq(treeTable.id, folder.id))
.returning()
@ -1058,8 +1114,10 @@ class Tree {
siteId,
tags,
meta,
// -> Uploading a file already in the folder takes the next free `name-1.ext`, rather than
// failing on something the uploader did not choose and cannot see
// -> Whatever the site's upload conflict behavior is, a name that is taken by the time the row
// is written takes the next free `name-1.ext`: the assets model settled the collisions it
// could see, and a file that appeared since must not fail on something the uploader did not
// choose and cannot see
onConflict: 'suffix'
})
}

@ -13,6 +13,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { setCssVar } from '@/helpers/cssVars'
import { stripPageExtension } from '@/helpers/pagePaths'
import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify'
@ -229,6 +230,21 @@ router.beforeEach(async (to, from) => {
await loadBootstrap()
}
/*
-> Page extensions
A path ending in one of the extensions the site's content is written in addresses the page
underneath it, so `/foo/bar.md` is `/foo/bar`. The server redirects a request that reaches it, but
a link inside a page is followed by the router alone -- which is what this is for. Below the
bootstrap above, since that is where the site's extensions come from. A `/_` route is the app
itself rather than a page, and is left alone as it is by the server.
*/
const withoutExtension = to.path.startsWith('/_')
? null
: stripPageExtension(to.path, siteStore.pageExtensions)
if (withoutExtension) {
return { path: withoutExtension, query: to.query, hash: to.hash, replace: true }
}
// -> Locale
if (
!commonStore.desiredLocale ||

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
263 icons.
264 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-double-right": {"body":"<path fill=\"currentColor\" d=\"M9.094 4.781L7.688 6.22l9.78 9.78l-9.78 9.781l1.406 1.438L20.313 16zm7 0L14.687 6.22L24.47 16l-9.782 9.781l1.407 1.438L27.312 16z\"/>","width":32,"height":32},
@ -47,6 +47,7 @@ export const BUNDLED_ICONS = {
"la:css3-alt": {"body":"<path fill=\"currentColor\" d=\"m6 4l2 21l8 3l8-3l2-21zm3.332 3h13.32l-.261 3l-5.696 3h5.428l-.512 6.008l.02-.008l-.276 3L16 24l-5.365-2l-.33-4h3.021l.156 2.033l2.518.871l2.521-.853l.346-4.051h-8.736l-.258-3l5.91-3H9.61z\"/>","width":32,"height":32},
"la:dharmachakra": {"body":"<path fill=\"currentColor\" d=\"M16 2.125c-.55 0-1 .45-1 1a1 1 0 0 0 .594.906a11.93 11.93 0 0 0-7.875 3.313a.95.95 0 0 0 .093-.375c0-.551-.449-1-1-1s-1 .449-1 1s.45 1 1 1a.95.95 0 0 0 .375-.094a11.95 11.95 0 0 0-3.156 7.719A1 1 0 0 0 3.125 15c-.55 0-1 .45-1 1s.45 1 1 1a1 1 0 0 0 .906-.594a11.95 11.95 0 0 0 3.157 7.719a.95.95 0 0 0-.375-.094c-.551 0-1 .45-1 1c0 .551.449 1 1 1s1-.449 1-1a.95.95 0 0 0-.094-.375a11.93 11.93 0 0 0 7.875 3.313a1 1 0 0 0-.594.906c0 .55.45 1 1 1s1-.45 1-1a1 1 0 0 0-.594-.906a11.93 11.93 0 0 0 7.875-3.313a.95.95 0 0 0-.093.375c0 .551.449 1 1 1s1-.449 1-1s-.45-1-1-1a.95.95 0 0 0-.375.094a11.95 11.95 0 0 0 3.156-7.719a1 1 0 0 0 .906.594c.55 0 1-.45 1-1s-.45-1-1-1a1 1 0 0 0-.906.594a11.95 11.95 0 0 0-3.157-7.719a1 1 0 0 0 .375.094c.551 0 1-.45 1-1c0-.551-.449-1-1-1s-1 .449-1 1c0 .136.043.254.094.375a11.93 11.93 0 0 0-7.875-3.313A1 1 0 0 0 17 3.125c0-.55-.45-1-1-1m-1.031 3.938H15v6.093a3.8 3.8 0 0 0-1.031.438L9.625 8.28a9.93 9.93 0 0 1 5.344-2.219zm2.031 0a9.96 9.96 0 0 1 5.375 2.218l-4.344 4.313A3.8 3.8 0 0 0 17 12.156zM8.219 9.719L12.563 14c-.184.313-.313.64-.407 1H6.062A9.94 9.94 0 0 1 8.22 9.719zm15.562 0A9.94 9.94 0 0 1 25.938 15h-6.094a4 4 0 0 0-.407-1zM16 14c1.117 0 2 .883 2 2s-.883 2-2 2s-2-.883-2-2s.883-2 2-2m-9.969 3h6.094c.094.36.25.688.438 1l-4.344 4.25C7.035 20.781 6.227 18.992 6.03 17zm13.844 0h6.094c-.196 1.992-1.004 3.781-2.188 5.25L19.438 18c.187-.313.343-.64.437-1m-5.906 2.406c.32.188.664.34 1.031.438v6.093a9.9 9.9 0 0 1-5.375-2.25zm4.062 0l4.344 4.282A9.9 9.9 0 0 1 17 25.938v-6.094a4.2 4.2 0 0 0 1.031-.438\"/>","width":32,"height":32},
"la:dice-d6": {"body":"<path fill=\"currentColor\" d=\"M16 2.938L4.656 7.061L4 7.313v15.282l.531.281l11 6l.469.25l.469-.25l11-6l.531-.281V7.312l-.656-.25zm0 2.124l8.375 3.032L16 11.906L7.625 8.094zM16 7c-1.105 0-2 .45-2 1s.895 1 2 1s2-.45 2-1s-.895-1-2-1M6 9.531l9 4.094v12.688l-9-4.907zm20 0v11.875l-9 4.907V13.624zM12.656 14c-.37 0-.656.355-.656.875c0 .668.355 1.36.875 1.656c.148.074.32.063.469.063c.222 0 .351-.07.5-.219c.074-.148.156-.36.156-.656c0-.668-.45-1.36-.969-1.656c-.148-.075-.226-.063-.375-.063m7.375 1a1 1 0 0 0-.25.094c-.61.261-1.125 1.125-1.125 1.906c0 .262-.02.512.157.688c.175.175.363.28.625.28c.175 0 .355-.007.53-.093c.61-.348 1.032-1.148 1.032-1.844c0-.61-.258-1.031-.781-1.031c-.043 0-.114-.012-.188 0m-9.906 1.281c-.371 0-.656.356-.656.875c0 .668.355 1.328.875 1.625a.9.9 0 0 0 .437.094c.223 0 .383-.07.531-.219c.075-.148.157-.39.157-.687c0-.668-.45-1.328-.969-1.625c-.148-.074-.227-.063-.375-.063m13.406 1.157c-.074.011-.164.019-.25.062c-.61.262-1.125 1.156-1.125 1.938c0 .261.012.511.188.687s.332.25.593.25c.176 0 .356.023.532-.063c.61-.347 1.031-1.148 1.031-1.843c0-.61-.258-1.032-.781-1.032c-.043 0-.114-.011-.188 0zm-15.875.968c-.37 0-.656.356-.656.875c0 .668.355 1.36.875 1.657c.148.074.32.062.469.062c.222 0 .351-.07.5-.219c.074-.148.156-.36.156-.656c0-.668-.45-1.36-.969-1.656c-.148-.074-.226-.063-.375-.063\"/>","width":32,"height":32},
"la:directions": {"body":"<path fill=\"currentColor\" d=\"M16 3a3 3 0 0 0-2.125.875l-.125.156l-9.719 9.719l-.156.125a3.023 3.023 0 0 0 0 4.25l10 10a3.023 3.023 0 0 0 4.25 0l10-10a3.023 3.023 0 0 0 0-4.25l-10-10A3 3 0 0 0 16 3m0 2c.254 0 .52.082.719.281l10 10a1.015 1.015 0 0 1 0 1.438l-10 10a1.015 1.015 0 0 1-1.438 0l-10-10a1.015 1.015 0 0 1 0-1.438l10-10c.2-.199.465-.281.719-.281m1 6v3h-4a2 2 0 0 0-2 2v3h2v-3h4v3l4-4z\"/>","width":32,"height":32},
"la:download": {"body":"<path fill=\"currentColor\" d=\"M15 4v16.563L9.719 15.28L8.28 16.72l7 7l.719.687l.719-.687l7-7l-1.438-1.438l-5.28 5.28V4zM7 26v2h18v-2z\"/>","width":32,"height":32},
"la:edit": {"body":"<path fill=\"currentColor\" d=\"M25 4.031c-.766 0-1.516.297-2.094.875L13 14.781l-.219.219l-.062.313l-.688 3.5l-.312 1.468l1.469-.312l3.5-.688l.312-.062l.219-.219l9.875-9.906A2.968 2.968 0 0 0 25 4.03zm0 1.938c.234 0 .465.12.688.343c.445.446.445.93 0 1.375L16 17.376l-1.719.344l.344-1.719l9.688-9.688c.222-.222.453-.343.687-.343zM4 8v20h20V14.812l-2 2V26H6V10h9.188l2-2z\"/>","width":32,"height":32},
"la:ellipsis-h": {"body":"<path fill=\"currentColor\" d=\"M6 14a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4m10 0a1.999 1.999 0 1 0 0 4a1.999 1.999 0 1 0 0-4\"/>","width":32,"height":32},

@ -0,0 +1,281 @@
<template>
<div class="editor-redirect">
<w-scroll-area style="height: 100%">
<div class="editor-redirect-form">
<w-card class="pb-2">
<w-card-header>{{ t('editor.redirect.title') }}</w-card-header>
<!-- ----------------------- -->
<!-- Title -->
<!-- ----------------------- -->
<w-item>
<blueprint-icon icon="new-document" />
<w-item-section>
<w-item-label>{{ t('editor.redirect.pageTitle') }}</w-item-label>
<w-item-label caption>{{ t('editor.redirect.pageTitleHint') }}</w-item-label>
</w-item-section>
<w-item-section>
<!--
The same title the header edits in place, so the two are one field with two places to
type it: both write to the store, and the header's watcher follows what is typed here.
-->
<w-input
outlined
dense
hide-bottom-space
:model-value="pageStore.title"
:aria-label="t(`editor.redirect.pageTitle`)"
@update:model-value="setTitle" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<!-- ----------------------- -->
<!-- Target -->
<!-- ----------------------- -->
<w-item>
<blueprint-icon icon="advance" />
<w-item-section>
<w-item-label>{{ t('editor.redirect.target') }}</w-item-label>
<w-item-label caption>{{ t('editor.redirect.targetHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:folder-open"
color="primary"
padding="xs md"
no-caps
:label="t(`editor.redirect.choose`)"
@click="chooseTarget" />
</w-item-section>
</w-item>
<!--
What was chosen, indented to the row above rather than made a row of its own: it is that
row's answer, and a `w-item` of its own would need an empty avatar section to line up
with. The icon says which kind it is, which is the only place that distinction shows now
that one dialog answers both halves of it.
-->
<div class="editor-redirect-field">
<div class="text-body2 font-robotomono editor-redirect-target" v-if="state.target">
<w-icon
class="mr-2"
:name="state.kind === `url` ? `la:globe` : `la:file-alt`"
size="sm" />
{{ state.target }}
</div>
<div class="text-caption opacity-60" v-else>
{{ t('editor.redirect.noTargetSelected') }}
</div>
</div>
<w-separator class="my-2" inset />
<!-- ----------------------- -->
<!-- Interstitial -->
<!-- ----------------------- -->
<w-item>
<blueprint-icon icon="timer" />
<w-item-section>
<w-item-label>{{ t('editor.redirect.showInterstitial') }}</w-item-label>
<w-item-label caption>{{ t('editor.redirect.showInterstitialHint') }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-toggle
:model-value="state.showInterstitial"
:aria-label="t(`editor.redirect.showInterstitial`)"
@update:model-value="setShowInterstitial" />
</w-item-section>
</w-item>
</w-card>
<!--
What the page will do, spelled out, because everything above is settings and none of it says
what a reader arriving here actually gets. Also where a half-filled form is reported: the
save is refused by the server either way, and this says so before it is attempted.
-->
<div
class="editor-redirect-summary"
:class="isFollowable(state) ? `is-ready` : `is-incomplete`">
<w-icon :name="isFollowable(state) ? `la:info-circle` : `la:exclamation-triangle`" />
<div class="pl-3">
<template v-if="!isFollowable(state)">
{{ t('editor.redirect.summaryIncomplete') }}
</template>
<template v-else-if="state.showInterstitial">
{{ t('editor.redirect.summaryInterstitial', { target: state.target }) }}
</template>
<template v-else>
{{ t('editor.redirect.summaryDirect', { target: state.target }) }}
</template>
</div>
</div>
</div>
</w-scroll-area>
</div>
</template>
<script setup>
import { defineAsyncComponent, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialog } from '@/composables/dialog'
import { isFollowable, parseRedirect, serializeRedirect } from '@/helpers/pageRedirect'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
/**
* The `redirect` editor: a page that sends its reader somewhere else.
*
* There is no content to write, so this is a form rather than an editor a title, where the page
* points, and whether the reader is told about it on the way. All three are the page's own fields:
* the title is the page's, and the other two are its content, as JSON. See `helpers/pageRedirect.js`.
*
* What the page then DOES with that is `PageRedirect.vue`, which is what the page view draws in place
* of an article.
*/
// STORES
const editorStore = useEditorStore()
const pageStore = usePageStore()
// I18N
const { t } = useI18n()
// DATA
/**
* The redirection being edited, which is also exactly what gets saved the form has no field that is
* not one of these three. Seeded from the stored content and written back by the watcher below.
*/
const state = reactive(parseRedirect(pageStore.content))
// WATCHERS
/*
The form IS the content, so the store follows it on every keystroke there is nothing here that a
save would collect afterwards.
Immediate, and deliberately not a change: this also writes the canonical spelling of what was
already stored, and seeds a page being created with an empty redirection. Neither is an edit, so
neither may set the unsaved-changes flag `touch` is called by the handlers instead, where a
person actually did something.
*/
watch(
state,
(value) => {
pageStore.content = serializeRedirect(value)
},
{ immediate: true, deep: true }
)
// METHODS
/** Say that the page has unsaved changes, which is what turns the header's Save button on. */
function touch() {
editorStore.lastChangeTimestamp = Temporal.Now.instant()
}
function setTitle(title) {
pageStore.title = title
touch()
}
function setShowInterstitial(showInterstitial) {
state.showInterstitial = showInterstitial
touch()
}
/**
* Picks where this page sends its reader: a page of this wiki, or any URL.
*
* One dialog for both, because they are one question the link picker asks it the way the rest of the
* app already asks it, and answers with which of its two tabs the answer came from. That is what
* `kind` is: the choice somebody made, rather than a guess made afterwards from the look of the
* string. It opens on the current target, so coming back starts from what is already set.
*
* Its "open in a new tab" offer is turned off. A redirection is not a link somebody clicks the
* reader is taken there so there is no tab to choose, and nowhere here to store the answer.
*/
function chooseTarget() {
dialog({
component: defineAsyncComponent(() => import('./LinkPickerDialog.vue')),
componentProps: {
title: t('editor.redirect.pickerTitle'),
okLabel: t('common.actions.select'),
initialHref: state.target,
newTabOption: false
}
}).onOk(({ href, kind }) => {
state.kind = kind === 'url' ? 'url' : 'page'
state.target = href
touch()
})
}
</script>
<style lang="scss">
.editor-redirect {
height: 100%;
@at-root .body--light & {
background-color: $grey-3;
}
@at-root .body--dark & {
background-color: $dark-6;
}
/* -> A form, not a document: it stops widening well before the column does */
&-form {
max-width: 780px;
margin: 0 auto;
padding: 24px 16px 48px;
}
/*
Lined up with the main section of the row above it: `w-item` pads 16px and its avatar section is
56px wide, so the field starts where that row's label does.
*/
&-field {
padding: 0 16px 8px 72px;
}
&-target {
display: flex;
align-items: center;
overflow-wrap: anywhere;
}
/*
The one line that says what a reader arriving at this page gets. Blue while the form is answerable
and amber while it is not -- the second is a warning about a save that will be refused, not an
error that has happened yet.
*/
&-summary {
display: flex;
align-items: flex-start;
margin-top: 16px;
padding: 12px 16px;
border-radius: 4px;
font-size: 0.8rem;
line-height: 1.4;
&.is-ready {
background-color: rgba(25, 118, 210, 0.1);
color: $blue-9;
@at-root .body--dark &.is-ready {
color: $blue-3;
}
}
&.is-incomplete {
background-color: rgba(255, 152, 0, 0.12);
color: $orange-9;
@at-root .body--dark &.is-incomplete {
color: $orange-3;
}
}
}
}
</style>

@ -317,7 +317,12 @@
</w-item-section>
<w-item-section>{{ t(`common.actions.edit`) }}</w-item-section>
</w-item>
<w-item clickable v-if="item.type === `page`" @click="rerenderPage(item)">
<!-- -> Nothing to render on a redirection: it has a target where a page has
content, and the endpoint behind this refuses any editor but markdown -->
<w-item
clickable
v-if="item.type === `page` && item.pageType !== `redirect`"
@click="rerenderPage(item)">
<w-item-section side>
<w-icon name="la:magic" color="orange" />
</w-item-section>

@ -70,6 +70,7 @@ import slugify from 'slugify'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
import { normalizePagePath } from '@/helpers/pagePaths'
// PROPS
@ -141,6 +142,8 @@ watch(
async function create() {
state.loading++
try {
// -> The name is a segment of every page path under the folder, and is corrected the way one is
state.path = normalizePagePath(state.path)
const isFormValid = await newFolderForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('fileman.createFolderInvalidData'))

@ -70,6 +70,7 @@ import slugify from 'slugify'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
import { normalizePagePath } from '@/helpers/pagePaths'
// PROPS
@ -143,6 +144,8 @@ watch(
async function rename() {
state.loading++
try {
// -> The name is a segment of every page path under the folder, and is corrected the way one is
state.path = normalizePagePath(state.path)
const isFormValid = await renameFolderForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('fileman.renameFolderInvalidData'))

@ -340,6 +340,12 @@ function selectItem(item) {
function submit() {
onDialogOK({
href: href.value,
/*
Which tab answered, so a caller that stores the two kinds differently does not have to work it
out from the string afterwards. It cannot be worked out reliably: `/help` is a page of this wiki
and a perfectly good relative URL elsewhere. This is the choice somebody made.
*/
kind: state.currentTab,
// -> Only ever true for a URL: a page of this wiki opens in the tab the reader is already in
openInNewTab: state.currentTab === 'url' && props.newTabOption && state.openInNewTab,
title: state.currentTab === 'page' ? state.pageTitle : ''

@ -299,7 +299,26 @@
v-model="state.current.target"
dense
hide-bottom-space
:aria-label="t(`navEdit.target`)" />
:aria-label="t(`navEdit.target`)">
<template #append>
<!--
Beside the field rather than in place of it: a path someone knows is quicker
typed than browsed to, and an external URL has nothing to browse. Same shape as
the icon picker's button one row up, for the same reason -- both open a chooser
for the field they sit in.
-->
<w-btn
flat
dense
round
icon="la:folder-open"
color="primary"
:aria-label="t(`common.actions.browse`)"
@click="browseTarget">
<w-tooltip>{{ t('common.actions.browse') }}</w-tooltip>
</w-btn>
</template>
</w-input>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
@ -442,8 +461,9 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { dialog } from '@/composables/dialog'
import { loading } from '@/composables/loading'
import { notify } from '@/composables/notify'
@ -533,6 +553,30 @@ function setItem(item) {
state.current = item
}
/**
* Picks the link's target: a page of this wiki, or any URL.
*
* The same dialog the markdown editor's Insert Link opens, with both of its tabs a navigation link
* goes to either, and which one it is is the reader's question rather than this panel's. It opens on
* whatever the field already holds, so coming back to a link that exists starts from that link.
*
* Its "open in a new tab" offer is turned off: this panel asks that one row down and stores the
* answer, so a second control for it could only disagree with the toggle that is actually saved.
*/
function browseTarget() {
dialog({
component: defineAsyncComponent(() => import('./LinkPickerDialog.vue')),
componentProps: {
title: t('navEdit.target'),
okLabel: t('common.actions.select'),
initialHref: state.current.target,
newTabOption: false
}
}).onOk(({ href }) => {
state.current.target = href
})
}
function addItem(type) {
const newItem = {
id: uuid(),

@ -13,11 +13,17 @@
class="page-actions flex flex-col items-stretch order-last"
:class="editorStore.isActive ? `is-editor` : ``">
<template v-if="userStore.can(`write:pages`)">
<!--
Off for a redirection: the panel is contents, tags, ratings, comments and scripts, all of them
about a page somebody reads. Disabled rather than hidden, because it is the rail's primary
action and the square it occupies is what the rest of the buttons are arranged under.
-->
<w-btn
class="aspect-square"
flat
icon="la:pen-nib"
:color="editorStore.isActive ? `white` : `deep-orange-9`"
:disable="isRedirect"
aria-label="Page Properties"
@click="togglePageProperties">
<w-tooltip anchor="center left" self="center right">Page Properties</w-tooltip>
@ -33,9 +39,10 @@
disable>
<w-tooltip anchor="center left" self="center right">Page Data</w-tooltip>
</w-btn>
<!-- -> Nothing can be pasted or dropped onto a redirection: it is a form, not a document -->
<w-btn
class="h-12"
v-if="editorStore.isActive"
v-if="editorStore.isActive && !isRedirect"
flat
color="white"
:text-color="hasPendingAssets ? `white` : `deep-orange-3`"
@ -86,32 +93,40 @@
</w-card>
</w-menu>
</w-btn>
<w-separator class="my-2" inset />
<!-- -> Nothing follows it on a redirection, and a rule with nothing under it is just a line -->
<w-separator class="my-2" v-if="!isRedirect" inset />
</template>
<!--
`read:history` is the permission that exists to say who may see what a page used to contain, so
the button follows it rather than page read access. The API asks the same question.
The three below are all about a page's TEXT: what it used to say, what it says in source, and
the things that can be done to that text. A redirection has none its content is a target, the
form above is the whole of it, and there is no render for any of these to be about.
-->
<w-btn
class="h-12"
v-if="userStore.can(`read:history`)"
flat
icon="la:history"
:color="editorStore.isActive ? `white` : `grey`"
aria-label="Page History"
@click="viewPageHistory">
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
</w-btn>
<w-btn
class="h-12"
flat
icon="la:code"
:color="editorStore.isActive ? `white` : `grey`"
aria-label="Page Source"
@click="viewPageSource">
<w-tooltip anchor="center left" self="center right">Page Source</w-tooltip>
</w-btn>
<template v-if="!(editorStore.isActive && editorStore.mode === `create`)">
<template v-if="!isRedirect">
<!--
`read:history` is the permission that exists to say who may see what a page used to contain, so
the button follows it rather than page read access. The API asks the same question.
-->
<w-btn
class="h-12"
v-if="userStore.can(`read:history`)"
flat
icon="la:history"
:color="editorStore.isActive ? `white` : `grey`"
aria-label="Page History"
@click="viewPageHistory">
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
</w-btn>
<w-btn
class="h-12"
flat
icon="la:code"
:color="editorStore.isActive ? `white` : `grey`"
aria-label="Page Source"
@click="viewPageSource">
<w-tooltip anchor="center left" self="center right">Page Source</w-tooltip>
</w-btn>
</template>
<template v-if="!isRedirect && !(editorStore.isActive && editorStore.mode === `create`)">
<w-separator class="my-2" inset />
<w-btn
class="h-12"
@ -245,6 +260,15 @@ const menuPendingAssets = ref(null)
const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0)
/**
* Whether the page this rail is for is a redirection one being read, edited or created alike, since
* `pageCreate` puts the editor on the page store as well.
*
* A redirection has no text, so most of this rail is about something that is not there: see the
* individual buttons for what each one loses.
*/
const isRedirect = computed(() => pageStore.editor === 'redirect')
// METHODS
function togglePageProperties() {

@ -89,11 +89,14 @@
to switch TO: its bell and its bell-solid carry an identical body in Iconify, so that pair
drew one drawing in two colours. (Names left unquoted on purpose the icon bundler scans
this file for quoted references and would keep bundling an icon nothing draws.)
Not offered on a redirection: a watch is an offer to be told when a page's content changes,
and nobody is reading this one they are passing through it.
-->
<w-btn
class="ml-4"
:class="{ 'is-ringing': state.bellRinging }"
v-if="userStore.authenticated"
v-if="userStore.authenticated && !isRedirect"
flat
dense
:icon="pageStore.isWatching ? `mdi:bell` : `mdi:bell-outline`"
@ -124,10 +127,13 @@
An empty queue is grey and an empty tray, sitting with Print as one more thing available
rather than one more thing to do; something waiting fills the tray and turns it orange, the
colour this row uses for what belongs to the reader. The count is on the badge either way.
A redirection takes no suggestions -- see the button below -- so there is never a queue on
one to review.
-->
<w-btn
class="ml-4"
v-if="pageStore.canReview"
v-if="pageStore.canReview && !isRedirect"
flat
dense
:color="pendingCount > 0 ? `deep-orange-9` : `grey`"
@ -218,8 +224,14 @@
For a reader who may read the page but not change it, and whose groups an approval rule lets
suggest edits to it. Same place and same shape as Edit, because it is the same intent -- what
differs is where the result goes.
Never on a redirection. A suggestion is a rewrite of a page's text put in front of a reviewer,
and a redirection has no text -- what it has is a target, which is a decision about where a
path leads rather than a contribution to read. The server still answers `canSuggestEdits` from
the approval rules, which are written against paths and know nothing about editors; this is
the one place that asks for it.
-->
<template v-else-if="!editorStore.isActive && pageStore.canSuggestEdits">
<template v-else-if="!editorStore.isActive && pageStore.canSuggestEdits && !isRedirect">
<w-btn
class="acrylic-btn ml-4"
flat
@ -363,6 +375,17 @@ const isEditing = computed(() => editorStore.isActive && !isSuggesting.value)
/** How many suggestions are waiting on this page, which is what the review badge counts. */
const pendingCount = computed(() => pageStore.pendingSubmissions.length)
/**
* Whether this is a redirection one being read, edited or created alike, since `pageCreate` puts the
* editor on the page store as well.
*
* What it takes out of this row is everything addressed to a READER of the page: watching it,
* suggesting a change to it, and reviewing the suggestions. Nobody stays on a redirection long enough
* for any of those to mean anything. The title, the icon and Edit stay, because those belong to
* whoever maintains it.
*/
const isRedirect = computed(() => pageStore.editor === 'redirect')
// DATA
const state = reactive({
@ -454,8 +477,15 @@ function onEditableBlur(field, event) {
}
async function discardChanges() {
// From create mode
if (editorStore.mode === 'create') {
/*
Abandoning a page that is being written, which is a different thing from reverting an edit: there
is nothing stored to go back to, so the editor closes and the reader is put back on the site.
`isActive` is part of the test, not just the mode. This button also appears with no editor open at
all -- the properties panel writes straight to the page store, and this is how those changes are
dropped -- and that is an edit to a page that exists, however the editor was last used.
*/
if (editorStore.isActive && editorStore.mode === 'create') {
editorStore.$patch({
isActive: false,
editor: ''
@ -475,13 +505,18 @@ async function discardChanges() {
loading.show()
try {
/*
The page is put back, and only then does the editor close. The other order draws the page view
for a moment at the route the editor was on, which a redirection reads as "nobody is holding
me" and acts on -- taking the author to its target instead of back to the page they discarded.
*/
await pageStore.cancelPageEdit()
editorStore.$patch({
isActive: false,
editor: '',
// -> Back to the ordinary meaning of the editor, or the next thing opened would inherit this one
mode: 'edit'
})
await pageStore.cancelPageEdit()
if (hadPendingChanges) {
notify({
type: 'positive',
@ -492,6 +527,9 @@ async function discardChanges() {
})
}
} catch (err) {
// -> The editor closes either way: the reader asked to leave it, and a page that would not
// reload is not a reason to keep them in it
editorStore.$patch({ isActive: false, editor: '', mode: 'edit' })
notify({
type: 'negative',
message: 'Failed to reload page state.'
@ -528,6 +566,18 @@ async function saveChangesCommit(closeAfter = false) {
message: 'Page saved successfully.'
})
if (closeAfter) {
/*
The editor closes onto the page, and for a redirection that page would take the author
straight to the target they just chose. `editorExitPath` holds it instead a change of query
on the route already showing, so nothing is loaded again. Every other page is left alone,
down to the fragment it was opened at.
Before the editor closes, and awaited: the page view drawn at the editor's route would read
the query as it stands and follow the redirection out from under this.
*/
if (pageStore.editor === 'redirect' && route.fullPath !== pageStore.editorExitPath) {
await router.replace(pageStore.editorExitPath)
}
editorStore.$patch({
isActive: false,
editor: ''

@ -29,11 +29,13 @@
<blueprint-icon icon="api" />
<w-item-section class="pr-2">New API Documentation</w-item-section>
</w-item>
<w-item clickable @click="create(`redirect`)">
<blueprint-icon icon="advance" />
<w-item-section class="pr-2">New Redirection</w-item-section>
</w-item>
</template>
<!-- -> Not an editor the site can turn off, because it authors nothing: a redirection is a page
with a target instead of a body -->
<w-item clickable @click="create(`redirect`)">
<blueprint-icon icon="advance" />
<w-item-section class="pr-2">New Redirection</w-item-section>
</w-item>
<template v-if="props.hideAssetBtn === false">
<w-separator class="my-2" inset />
<w-item clickable @click="openFileManager">
@ -83,7 +85,6 @@ const props = defineProps({
const emit = defineEmits(['newFolder', 'newPage'])
// STORES
const editorStore = useEditorStore()
@ -97,18 +98,18 @@ const { t } = useI18n()
// METHODS
async function create (editor) {
async function create(editor) {
loading.show()
emit('newPage')
await pageStore.pageCreate({ editor, basePath: props.basePath })
loading.hide()
}
function openFileManager () {
function openFileManager() {
siteStore.openFileManager()
}
function newFolder () {
function newFolder() {
emit('newFolder')
}
</script>

@ -35,9 +35,7 @@
:bar-style="siteStore.scrollStyle.bar"
style="height: calc(100% - 50px)">
<w-card-section id="refCardInfo">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:info-circle" size="xs" /> {{ t('editor.props.info') }}
</div>
<div class="w-section-header">{{ t('editor.props.info') }}</div>
<w-form class="gap-2">
<w-input
ref="iptTitle"
@ -85,9 +83,7 @@
</w-form>
</w-card-section>
<w-card-section class="alt-card" id="refCardPublishState">
<div class="text-overline pb-1 items-center flex">
<w-icon class="mr-2" name="la:power-off" size="xs" /> {{ t('editor.props.publishState') }}
</div>
<div class="w-section-header">{{ t('editor.props.publishState') }}</div>
<w-form class="gap-4">
<div>
<w-btn-toggle
@ -117,9 +113,7 @@
</w-form>
</w-card-section>
<w-card-section id="refCardRelations">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:sun" size="xs" /> {{ t('editor.props.relations') }}
</div>
<div class="w-section-header">{{ t('editor.props.relations') }}</div>
<w-list
class="rounded mb-2 bg-white dark:bg-black/20"
v-if="pageStore.relations.length > 0"
@ -158,9 +152,7 @@
</w-btn>
</w-card-section>
<w-card-section class="alt-card" id="refCardScripts">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:code" size="xs" /> {{ t('editor.props.scripts') }}
</div>
<div class="w-section-header">{{ t('editor.props.scripts') }}</div>
<w-btn
class="w-full"
:label="t(`editor.props.jsLoad`)"
@ -193,9 +185,7 @@
</w-btn>
</w-card-section>
<w-card-section class="pb-6" id="refCardSidebar">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:ruler-vertical" size="xs" /> {{ t('editor.props.sidebar') }}
</div>
<div class="w-section-header">{{ t('editor.props.sidebar') }}</div>
<w-form class="gap-4 pt-2">
<div>
<w-toggle
@ -245,9 +235,7 @@
</w-form>
</w-card-section>
<w-card-section class="alt-card pb-6" id="refCardSocial">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:comments" size="xs" /> {{ t('editor.props.social') }}
</div>
<div class="w-section-header">{{ t('editor.props.social') }}</div>
<w-form class="gap-4 pt-2">
<div>
<w-toggle
@ -279,15 +267,11 @@
</w-form>
</w-card-section>
<w-card-section class="pb-6" id="refCardTags">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:tags" size="xs" /> {{ t('editor.props.tags') }}
</div>
<div class="w-section-header">{{ t('editor.props.tags') }}</div>
<page-tags edit />
</w-card-section>
<w-card-section class="alt-card pb-6" id="refCardVisibility">
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:eye" size="xs" /> {{ t('editor.props.visibility') }}
</div>
<div class="w-section-header">{{ t('editor.props.visibility') }}</div>
<w-form class="gap-4 pt-2">
<div>
<w-toggle
@ -488,5 +472,22 @@ onMounted(() => {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
/*
The section headings, in the treatment the profile pages use.
`.w-section-header` carries its own 16px inset and expects to sit in a column that has none --
inside a `w-card-section` it would be indented twice, and its wash would stop short of the panel
on both sides. So the section's padding is cancelled around it: the band then spans the panel and
its text lines up with the fields beneath it, exactly as on a profile page. The top padding is
given back so the heading sits where the section's own padding had it.
The tinted `alt-card` sections keep their stripe: the heading is inside the section, so the wash
is drawn over whichever surface that section has.
*/
.w-section-header {
margin: -16px -16px 10px;
padding-top: 16px;
}
}
</style>

@ -0,0 +1,263 @@
<template>
<div class="page-placeholder">
<!-- ----------------------- -->
<!-- Nowhere to go -->
<!-- ----------------------- -->
<template v-if="problem">
<w-icon class="page-placeholder-icon" name="la:exclamation-triangle" />
<div class="text-h6">{{ t(`common.redirect.${problem}`) }}</div>
<div class="text-body2 mt-1 opacity-60" v-if="canEditPage">
{{ t('common.redirect.brokenHint') }}
</div>
<div class="text-caption font-robotomono mt-3 opacity-50" v-if="redirect.target">
{{ redirect.target }}
</div>
<w-btn
class="mt-6"
v-if="canEditPage"
unelevated
icon="la:edit"
color="primary"
padding="xs lg"
:label="t(`common.actions.edit`)"
@click="editPage" />
</template>
<!-- ----------------------- -->
<!-- Held, so the page can be worked on -->
<!-- ----------------------- -->
<template v-else-if="!following">
<w-icon class="page-placeholder-icon" name="la:directions" />
<div class="text-h6">{{ t('common.redirect.held') }}</div>
<div class="text-caption font-robotomono mt-3 opacity-50">{{ redirect.target }}</div>
<w-btn
class="mt-6"
unelevated
icon="la:arrow-right"
color="primary"
padding="xs lg"
:label="t(`common.redirect.follow`)"
@click="follow" />
</template>
<!-- ----------------------- -->
<!-- On the way -->
<!-- ----------------------- -->
<template v-else>
<w-icon class="page-placeholder-icon" name="la:directions" />
<!--
`aria-live`, because nothing here is clicked: a reader on a screen reader is told where they
are being taken at the moment the page announces it, not when they get around to reading it.
-->
<div class="text-h6" role="status" aria-live="polite">
{{ t('common.redirect.redirectingTo', { target: redirect.target }) }}
</div>
<!-- -> The way out of a wait, and the way past it: a reader who does not want to sit through
the notice can go now, and one who lands here with scripting half-loaded has a link -->
<w-btn
class="mt-6"
unelevated
icon="la:arrow-right"
color="primary"
padding="xs lg"
:label="t(`common.redirect.goNow`)"
@click="go" />
</template>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { loading } from '@/composables/loading'
import { isFollowable, parseRedirect, REDIRECT_INTERSTITIAL_MS } from '@/helpers/pageRedirect'
import { usePageStore } from '@/stores/page'
import { useUserStore } from '@/stores/user'
/**
* What the page view draws in place of an article for a page authored with the `redirect` editor.
*
* A redirection has no body: it sends its reader somewhere else, either straight away or after a
* short notice saying where. See `helpers/pageRedirect.js` for what is stored, and
* `EditorRedirect.vue` for where it is filled in.
*
* **`?redirect=no` holds it**, which is what makes a redirection maintainable: without it, a page
* whose whole purpose is to bounce the reader elsewhere cannot be opened by the person who has to
* fix it. The link a redirection's own screens hand out carries it, so following a chain of them
* backwards keeps working.
*/
/**
* How many redirections may be followed one after another before the chain is treated as a loop.
*
* `A → B → A` is a browser that never stops navigating, and no single page can see it: each one is
* pointing somewhere perfectly reasonable. Only the count across them says otherwise. Generous enough
* that a chain nobody meant to build still works, and small enough to stop before anything hangs.
*/
const MAX_HOPS = 5
/**
* Redirections followed in a row. Deliberately outside the component, because that is the whole point:
* the page view keeps this one component mounted from a redirection to the next, and the count is
* about the chain rather than about any page in it. Reset the moment the chain ends a page that is
* read rather than followed unmounts this, and one held by `?redirect=no` is not being followed.
*/
let hops = 0
// STORES
const pageStore = usePageStore()
const userStore = useUserStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// DATA
/** The timer behind the interstitial. Cleared on the way out, so a page left early does not fire. */
let timer = null
/** Whether this page ends a chain that has gone on too long; see `MAX_HOPS`. */
const chainStopped = ref(false)
// COMPUTED
const redirect = computed(() => parseRedirect(pageStore.content))
/**
* Why this redirection cannot be followed, if it cannot: nothing was filled in, it points at the page
* it is on, or it is the last hop of a chain that has come back around. All three would leave a reader
* bouncing rather than arriving.
*
* Doubles as the name of the string that says so.
*/
const problem = computed(() => {
if (!isFollowable(redirect.value)) {
return 'broken'
}
if (chainStopped.value) {
return 'chain'
}
return redirect.value.kind === 'page' && isSelf(redirect.value.target) ? 'loop' : null
})
/**
* Whether the reader is on their way, rather than being held.
*
* Two things hold a redirection. `?redirect=no` is the one a person asks for. The other is an editor
* route: `/_edit/<path>` and `/_create/<editor>` load the page BEFORE they open the editor on it, and
* in the gap between the two the page view is drawn for a page that is already known to be a
* redirection which would take the author to the target instead of showing them the form for it.
* Nobody on those routes is reading the page, so nothing there is ever followed.
*/
const following = computed(
() =>
route.query.redirect !== 'no' &&
!route.path.startsWith('/_edit') &&
!route.path.startsWith('/_create')
)
/** Whoever can save the page is who the broken-redirection screen offers a way to fix it to. */
const canEditPage = computed(() =>
['write:pages', 'manage:pages'].some((permission) =>
userStore.pagePermissions.includes(permission)
)
)
// WATCHERS
/*
Keyed on the page rather than run on mount: this component stays mounted from one redirection to the
next -- the page view swaps the store's contents under it -- so a mount hook would fire for the
first one only.
`immediate`, because arriving at a redirection directly is the ordinary case.
*/
watch(
() => [pageStore.id, redirect.value.target, following.value],
() => {
clear()
if (!following.value) {
// -> Held, so nothing is being followed and whatever came before it was not a chain
hops = 0
chainStopped.value = false
return
}
if (problem.value) {
return
}
/*
Counted here rather than in `go`, so that the interstitial on the page that breaks the chain is
never shown: it would say the reader is on their way somewhere they are about to be told they
cannot go. A URL target leaves the app entirely, which ends any chain by itself.
*/
if (redirect.value.kind === 'page' && ++hops > MAX_HOPS) {
chainStopped.value = true
return
}
if (!redirect.value.showInterstitial) {
go()
return
}
timer = setTimeout(go, REDIRECT_INTERSTITIAL_MS)
},
{ immediate: true }
)
onBeforeUnmount(() => {
clear()
// -> Whatever comes next is read rather than followed, so the chain ends here
hops = 0
})
// METHODS
function clear() {
clearTimeout(timer)
timer = null
}
/** Whether a page target is the page holding it, however the two are spelled. */
function isSelf(target) {
const stored = `/${pageStore.path}`.replace(/\/+$/, '')
const to = target.replace(/\/+$/, '').toLowerCase()
return to === stored.toLowerCase() || (stored === '/home' && to === '')
}
/**
* Take the reader on.
*
* `replace` rather than a push, both ways: a redirection is not somewhere anyone meant to be, and
* leaving it in the history means the back button lands on it and bounces straight forward again.
*/
function go() {
clear()
if (problem.value) {
return
}
if (redirect.value.kind === 'url') {
// -> Leaving the app entirely, so the loading bar is what stands in for the wait
loading.show()
window.location.replace(redirect.value.target)
return
}
router.replace(redirect.value.target)
}
/** Follow it deliberately, from the screen `?redirect=no` holds the reader on. */
function follow() {
router.replace({ path: route.path, query: { ...route.query, redirect: undefined } })
}
function editPage() {
router.push(`/_edit/${pageStore.path}`)
}
</script>

@ -7,10 +7,9 @@
<w-card-section>
<!--
`self-start` on every button: WForm stacks with `flex-col`, so a button left to its own devices
stretches to the full width of the dialog. The section titles pull the item below them up, since
the form's `gap-4` belongs between sections rather than between a title and its own content.
stretches to the full width of the dialog.
-->
<div class="text-overline -mb-3">{{ t('editor.pageRel.position') }}</div>
<div class="w-section-header">{{ t('editor.pageRel.position') }}</div>
<w-form class="gap-4 pt-4">
<div>
<w-btn-toggle
@ -25,7 +24,7 @@
{ label: t('editor.pageRel.right'), value: 'right' }
]" />
</div>
<div class="text-overline -mb-3">{{ t('editor.pageRel.button') }}</div>
<div class="w-section-header">{{ t('editor.pageRel.button') }}</div>
<!-- One item, so the two fields are only ever as far apart as their own margins -->
<div class="flex flex-col">
<w-input
@ -41,15 +40,22 @@
:label="t(`editor.pageRel.caption`)"
v-model="state.caption" />
</div>
<!--
`-mt-2` so this sits the same distance below the field above it as the two fields sit from
each other. An outlined field carries `my-2` around its control, so the form's `gap-4`
lands 8px further down than the 16px those margins put between two stacked fields. Applies
whichever field is last: the caption is hidden for a centred relation, and the label above
it is spaced the same way.
-->
<w-btn
class="self-start rounded"
class="self-start rounded -mt-2"
:label="t(`editor.pageRel.selectIcon`)"
color="primary"
outline>
<w-tooltip>{{ t('iconPicker.open') }}</w-tooltip>
<w-menu content-class="shadow-7"><icon-picker-dialog v-model="state.icon" /></w-menu>
</w-btn>
<div class="text-overline -mb-3">{{ t('editor.pageRel.target') }}</div>
<div class="w-section-header">{{ t('editor.pageRel.target') }}</div>
<div class="flex flex-nowrap items-center gap-3">
<w-btn
class="flex-none rounded"
@ -62,7 +68,7 @@
{{ state.target || '—' }}
</div>
</div>
<div class="text-overline -mb-3">{{ t('editor.pageRel.preview') }}</div>
<div class="w-section-header">{{ t('editor.pageRel.preview') }}</div>
<w-btn
v-if="state.pos === `left`"
class="self-start"
@ -293,3 +299,27 @@ onMounted(() => {
})
})
</script>
<style lang="scss">
/*
The section headings, in the treatment the profile pages and the page properties panel use.
`.w-section-header` carries its own 16px inset and expects a column that has none, so the card
section's padding is cancelled around it -- the band then spans the dialog and its text lines up
with the fields under it. Its bottom margin goes too: three of these are items in the form's
`flex-col gap-4`, so the 16px gap is already the space beneath them, and the heading's own margin
would add to it rather than replace it. That gap is also what clears the two rules trailing below
the heading, which is what the `-mb-3` these replaces was fighting.
*/
.page-relation-dialog {
.w-section-header {
margin: 0 -16px;
}
/* -> The first one is also the top of the section, so it takes that padding as well */
> .w-card-section > .w-section-header:first-child {
margin-top: -16px;
padding-top: 16px;
}
}
</style>

@ -155,6 +155,7 @@ import Tree from '@/components/TreeNav.vue'
import { useSiteStore } from '@/stores/site'
import { apiErrorMessage } from '@/helpers/apiError'
import { normalizePagePath } from '@/helpers/pagePaths'
// PROPS
@ -287,6 +288,9 @@ async function save() {
})
return
}
// -> A path is a URL: casing and spaces are corrected rather than refused, the way the server does
// it, and the field is left showing what will actually be saved
state.path = normalizePagePath(state.path)
if (!/^[a-z0-9-]+$/.test(state.path)) {
notify({
type: 'negative',

@ -87,8 +87,10 @@ onMounted(async () => {
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
// -> The stored name is not always the one asked for: a file already in the folder gets the
// next free `name-1.ext`, and the content has to point at what was actually stored
// -> The stored name is not always the one asked for: what happens to a file already in the
// folder is the site's upload conflict behavior to decide it may be replaced, or the
// arrival may take the next free `name-1.ext` so the content has to point at what the
// server says it stored
const storedPath = assetPath(resp?.asset?.folderPath, resp?.asset?.fileName)
pageStore.content = pageStore.content.replaceAll(item.blobUrl, storedPath)
replacements.push({ from: item.blobUrl, to: storedPath })

@ -0,0 +1,40 @@
/**
* The one spelling a page path has.
*
* Mirrors `normalizePagePath` in the backend's `helpers/common.ts`, so that a path typed into a
* dialog is corrected in front of the person typing it rather than silently changed by the server
* after they hit save. Whether what comes out is *allowed* is still each field's own rule this only
* settles casing and spaces.
*/
export function normalizePagePath(input) {
return (input ?? '')
.trim()
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replaceAll(/\s+/g, '-')
.toLowerCase()
}
/**
* Drop a site's page extension from the end of a URL path.
*
* The server redirects these too, but a link inside page content is followed by the router without
* ever asking it so `/foo/bar.md` written into a page has to resolve to `/foo/bar` here as well.
* Mirrors `stripPageExtension` in the backend's `helpers/common.ts`.
*
* @param extensions Lowercase and without the dot, as `siteStore.pageExtensions` holds them
* @returns The path without the extension, or null if it does not end in one of them
*/
export function stripPageExtension(urlPath, extensions) {
if (!extensions?.length) {
return null
}
const dot = urlPath.lastIndexOf('.')
if (dot < 1 || urlPath[dot - 1] === '/' || urlPath.lastIndexOf('/') > dot) {
return null
}
if (!extensions.includes(urlPath.slice(dot + 1).toLowerCase())) {
return null
}
return urlPath.slice(0, dot)
}

@ -0,0 +1,65 @@
/**
* What a redirection page holds instead of a body.
*
* A redirection is an ordinary page authored with the `redirect` editor: it has a path, a title and a
* place in the tree, and nothing to read. Where it points is its content, as JSON see
* `normalizeRedirectContent` in the backend's `models/pages.ts`, which is the authority on the shape
* and refuses a save that does not match it. This file is the same reading, in front of the author:
* the editor round-trips through it, and the page view follows what it returns.
*/
/**
* How long the interstitial is shown before the reader is taken on, in milliseconds.
*
* Long enough to read one line and see where they are going, short enough that nobody waits on it.
*/
export const REDIRECT_INTERSTITIAL_MS = 2500
/** An empty redirection, which is what a page being created starts as. */
export function emptyRedirect() {
return { kind: 'page', target: '', showInterstitial: false }
}
/**
* Read a stored redirection. Never throws: content that is missing or unparseable comes back as an
* empty redirection, which the editor opens on and the page view reports as having nowhere to go.
*/
export function parseRedirect(content) {
let parsed = null
try {
parsed = JSON.parse(content ?? '')
} catch {
// -> An empty redirection is the answer; see above
}
return {
kind: parsed?.kind === 'url' ? 'url' : 'page',
target: typeof parsed?.target === 'string' ? parsed.target.trim() : '',
showInterstitial: parsed?.showInterstitial === true
}
}
/** The canonical spelling of a redirection, which is what gets saved. */
export function serializeRedirect({ kind, target, showInterstitial } = {}) {
return JSON.stringify({
kind: kind === 'url' ? 'url' : 'page',
target: (target ?? '').trim(),
showInterstitial: showInterstitial === true
})
}
/**
* Whether a redirection can actually be followed.
*
* The same two rules the server enforces: a page target is a rooted path within this wiki, and a URL
* target is a complete `http(s)` address anything else is either not a destination or, for
* `javascript:`, a link nobody chose to follow.
*/
export function isFollowable({ kind, target } = {}) {
const value = (target ?? '').trim()
if (value.length < 1) {
return false
}
return kind === 'url'
? /^https?:\/\/\S/i.test(value)
: value.startsWith('/') && !value.startsWith('//')
}

@ -0,0 +1,69 @@
/**
* The images a site has of its own its logo, its favicon and the backdrop of its login page.
*
* Uploading one is the same exchange whichever it is, and the accepted formats have to agree with
* what the endpoint checks, so both live here rather than in each admin view that offers an upload.
*/
/** What the endpoint accepts, mirroring the formats it recognizes from the bytes themselves. */
export const SITE_IMAGE_TYPES = [
'image/svg+xml',
'image/png',
'image/jpeg',
'image/webp',
'image/gif'
]
/**
* Ask for an image file.
*
* @returns The chosen file, or null if the picker was dismissed
*/
export function pickSiteImage() {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = SITE_IMAGE_TYPES.join(',')
input.onchange = (ev) => resolve(ev.target.files?.[0] ?? null)
// -> Dismissing the picker fires no `change` event, so the promise would otherwise never settle
input.oncancel = () => resolve(null)
input.click()
})
}
/**
* Whether a chosen file is one the endpoint will take. The picker's filter is a suggestion the user
* can override, and the server checks the bytes anyway; asking here beats a 415 with nothing to
* explain it.
*/
export function isAcceptedSiteImage(file) {
return SITE_IMAGE_TYPES.includes(file.type)
}
/**
* Replace one of a site's images.
*
* @param kind One of `logo`, `favicon` or `loginBg`
*/
export async function uploadSiteImage(siteId, kind, file) {
// -> The image is the request body itself: the endpoint takes the raw file, not a form
const resp = await API_CLIENT.put(`sites/${siteId}/images/${kind}`, {
body: file,
headers: {
'content-type': file.type
}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
}
/**
* Remove one of a site's images, leaving the built-in default in its place.
*/
export async function clearSiteImage(siteId, kind) {
const resp = await API_CLIENT.delete(`sites/${siteId}/images/${kind}`).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
}

@ -310,13 +310,22 @@
<w-item-label caption>{{ t(`admin.general.logoUplHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn
label="Upload"
unelevated
icon="la:upload"
color="primary"
text-color="white"
@click="uploadLogo" />
<div class="flex gap-2">
<w-btn
:label="t(`common.actions.upload`)"
unelevated
icon="la:upload"
color="primary"
text-color="white"
@click="uploadLogo" />
<w-btn
:label="t(`common.actions.clear`)"
outline
icon="la:times"
color="primary"
:disable="!state.hasLogo"
@click="clearLogo" />
</div>
</w-item-section>
</div>
<w-toolbar class="bg-header mt-4 rounded text-white" style="height: 64px">
@ -365,13 +374,22 @@
<w-item-label caption>{{ t(`admin.general.faviconHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn
label="Upload"
unelevated
icon="la:upload"
color="primary"
text-color="white"
@click="uploadFavicon" />
<div class="flex gap-2">
<w-btn
:label="t(`common.actions.upload`)"
unelevated
icon="la:upload"
color="primary"
text-color="white"
@click="uploadFavicon" />
<w-btn
:label="t(`common.actions.clear`)"
outline
icon="la:times"
color="primary"
:disable="!state.hasFavicon"
@click="clearFavicon" />
</div>
</w-item-section>
</div>
<div class="admin-general-favicontabs mt-4">
@ -441,21 +459,6 @@
:aria-label="t(`admin.general.uploadConflictBehavior`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="rename" />
<w-item-section>
<w-item-label>{{ t(`admin.general.uploadNormalizeFilename`) }}</w-item-label>
<w-item-label caption>{{
t(`admin.general.uploadNormalizeFilenameHint`)
}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.uploads.normalizeFilename"
:aria-label="t(`admin.general.uploadNormalizeFilename`)" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- URL Handling -->
@ -476,19 +479,6 @@
:aria-label="t(`admin.general.pageExtensions`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="lowercase" />
<w-item-section>
<w-item-label>{{ t(`admin.general.pageCasing`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.pageCasingHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.pageCasing"
:aria-label="t(`admin.general.pageCasing`)" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- SEO -->
@ -548,6 +538,13 @@ import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import {
clearSiteImage,
isAcceptedSiteImage,
pickSiteImage,
uploadSiteImage
} from '@/helpers/siteImages'
import { toMerged } from 'es-toolkit/object'
// STORES
@ -580,7 +577,6 @@ function defaultConfig() {
contentLicense: '',
footerExtra: '',
pageExtensions: '',
pageCasing: false,
logoText: false,
ratings: {
index: false,
@ -614,6 +610,10 @@ function defaultConfig() {
const state = reactive({
loading: 0,
assetTimestamp: new Date().toISOString(),
// -> Whether this site has a logo / favicon of its own, i.e. whether there is anything to clear.
// The previews always render: without one they show the default that is served instead.
hasLogo: false,
hasFavicon: false,
config: defaultConfig()
})
@ -668,6 +668,8 @@ async function load() {
...resp,
pageExtensions: resp.pageExtensions.join(',')
})
state.hasLogo = resp?.assets?.logo ?? false
state.hasFavicon = resp?.assets?.favicon ?? false
loading.hide()
state.loading--
}
@ -694,12 +696,10 @@ async function save() {
contentLicense: state.config.contentLicense ?? '',
footerExtra: state.config.footerExtra ?? '',
pageExtensions: parsePageExtensions(state.config.pageExtensions),
pageCasing: state.config.pageCasing ?? false,
logoText: state.config.logoText ?? false,
sitemap: state.config.sitemap ?? false,
uploads: {
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite',
normalizeFilename: state.config.uploads?.normalizeFilename ?? false
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite'
},
robots: {
index: state.config.robots?.index ?? false,
@ -746,115 +746,107 @@ async function save() {
}
async function uploadLogo() {
const input = document.createElement('input')
input.type = 'file'
input.onchange = async (e) => {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
context: {
uploadMode: true
},
mutation: `
mutation uploadLogo (
$id: UUID!
$image: Upload!
) {
uploadSiteLogo (
id: $id
image: $image
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
image: e.target.files[0]
}
})
if (resp?.data?.uploadSiteLogo?.operation?.succeeded) {
notify({
type: 'positive',
message: t('admin.general.logoUploadSuccess')
})
state.assetTimestamp = new Date().toISOString()
} else {
throw new Error(
resp?.data?.uploadSiteLogo?.operation?.message || 'An unexpected error occured.'
)
}
} catch (err) {
notify({
type: 'negative',
message: 'Failed to upload site logo.',
caption: err.message
})
}
state.loading--
const file = await pickSiteImage()
if (!file) {
return
}
if (!isAcceptedSiteImage(file)) {
notify({
type: 'negative',
message: t('admin.general.logoUploadFailed'),
caption: t('admin.general.imageUploadInvalidType')
})
return
}
state.loading++
try {
await uploadSiteImage(adminStore.currentSiteId, 'logo', file)
notify({
type: 'positive',
message: t('admin.general.logoUploadSuccess')
})
state.hasLogo = true
state.assetTimestamp = new Date().toISOString()
} catch (err) {
notify({
type: 'negative',
message: t('admin.general.logoUploadFailed'),
caption: err.message
})
}
state.loading--
}
input.click()
async function clearLogo() {
state.loading++
try {
await clearSiteImage(adminStore.currentSiteId, 'logo')
notify({
type: 'positive',
message: t('admin.general.logoClearSuccess')
})
state.hasLogo = false
state.assetTimestamp = new Date().toISOString()
} catch (err) {
notify({
type: 'negative',
message: t('admin.general.logoClearFailed'),
caption: err.message
})
}
state.loading--
}
async function uploadFavicon() {
const input = document.createElement('input')
input.type = 'file'
input.onchange = async (e) => {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
context: {
uploadMode: true
},
mutation: `
mutation uploadFavicon (
$id: UUID!
$image: Upload!
) {
uploadSiteFavicon (
id: $id
image: $image
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: adminStore.currentSiteId,
image: e.target.files[0]
}
})
if (resp?.data?.uploadSiteFavicon?.operation?.succeeded) {
notify({
type: 'positive',
message: t('admin.general.faviconUploadSuccess')
})
state.assetTimestamp = new Date().toISOString()
} else {
throw new Error(
resp?.data?.uploadSiteFavicon?.operation?.message || 'An unexpected error occured.'
)
}
} catch (err) {
notify({
type: 'negative',
message: 'Failed to upload site favicon.',
caption: err.message
})
}
state.loading--
const file = await pickSiteImage()
if (!file) {
return
}
if (!isAcceptedSiteImage(file)) {
notify({
type: 'negative',
message: t('admin.general.faviconUploadFailed'),
caption: t('admin.general.imageUploadInvalidType')
})
return
}
state.loading++
try {
await uploadSiteImage(adminStore.currentSiteId, 'favicon', file)
notify({
type: 'positive',
message: t('admin.general.faviconUploadSuccess')
})
state.hasFavicon = true
state.assetTimestamp = new Date().toISOString()
} catch (err) {
notify({
type: 'negative',
message: t('admin.general.faviconUploadFailed'),
caption: err.message
})
}
state.loading--
}
input.click()
async function clearFavicon() {
state.loading++
try {
await clearSiteImage(adminStore.currentSiteId, 'favicon')
notify({
type: 'positive',
message: t('admin.general.faviconClearSuccess')
})
state.hasFavicon = false
state.assetTimestamp = new Date().toISOString()
} catch (err) {
notify({
type: 'negative',
message: t('admin.general.faviconClearFailed'),
caption: err.message
})
}
state.loading--
}
// MOUNTED

@ -2,7 +2,9 @@
<w-page class="admin-login">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-bunch-of-keys-animated.svg" />
<img
class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-bunch-of-keys-animated.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.login.title') }}</div>
@ -50,21 +52,40 @@
<w-card-header>{{ t('admin.login.experience') }}</w-card-header>
<w-item>
<blueprint-icon
class="self-start"
icon="full-image"
indicator
:indicator-text="t(`admin.extensions.requiresSharp`)" />
<w-item-section>
<w-item-label>{{ t(`admin.login.background`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.login.backgroundHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn
label="Upload"
unelevated
icon="la:upload"
color="primary"
text-color="white"
@click="uploadBg" />
<div class="flex">
<w-item-section>
<w-item-label>{{ t(`admin.login.background`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.login.backgroundHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<div class="flex gap-2">
<w-btn
:label="t(`common.actions.upload`)"
unelevated
icon="la:upload"
color="primary"
text-color="white"
@click="uploadBg" />
<w-btn
:label="t(`common.actions.clear`)"
outline
icon="la:times"
color="primary"
:disable="!state.hasBg"
@click="clearBg" />
</div>
</w-item-section>
</div>
<img
v-if="adminStore.currentSiteId"
class="admin-login-bg mt-4"
:src="`/_site/` + adminStore.currentSiteId + `/loginBg?` + state.assetTimestamp"
:alt="t(`admin.login.background`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
@ -221,6 +242,13 @@ import { loading } from '@/composables/loading'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
import {
clearSiteImage,
isAcceptedSiteImage,
pickSiteImage,
uploadSiteImage
} from '@/helpers/siteImages'
import { toMerged } from 'es-toolkit/object'
import { Sortable } from 'sortablejs-vue3'
@ -260,7 +288,11 @@ const state = reactive({
invalidCharsRegex: /^[^<>"]+$/,
loading: 0,
config: defaultConfig(),
providers: []
providers: [],
// -> Whether this site has a background of its own, i.e. whether there is anything to clear. The
// preview always renders: without one it shows the default the login page falls back to.
hasBg: false,
assetTimestamp: new Date().toISOString()
})
const sortableOptions = {
@ -291,6 +323,7 @@ async function load() {
])
state.config = toMerged(defaultConfig(), site?.auth ?? {})
state.providers = providers ?? []
state.hasBg = site?.assets?.loginBg ?? false
} catch (err) {
notify({
type: 'negative',
@ -347,13 +380,56 @@ function updateAuthPosition(ev) {
state.providers.splice(ev.newIndex, 0, item)
}
function uploadBg() {
// TODO: needs a multipart upload endpoint for site assets, which does not exist yet the same
// blocker as the logo and favicon uploads in the general view.
notify({
type: 'warning',
message: t('admin.login.bgUploadUnavailable')
})
async function uploadBg() {
const file = await pickSiteImage()
if (!file) {
return
}
if (!isAcceptedSiteImage(file)) {
notify({
type: 'negative',
message: t('admin.login.bgUploadFailed'),
caption: t('admin.login.bgUploadInvalidType')
})
return
}
state.loading++
try {
await uploadSiteImage(adminStore.currentSiteId, 'loginBg', file)
notify({
type: 'positive',
message: t('admin.login.bgUploadSuccess')
})
state.hasBg = true
state.assetTimestamp = new Date().toISOString()
} catch (err) {
notify({
type: 'negative',
message: t('admin.login.bgUploadFailed'),
caption: err.message
})
}
state.loading--
}
async function clearBg() {
state.loading++
try {
await clearSiteImage(adminStore.currentSiteId, 'loginBg')
notify({
type: 'positive',
message: t('admin.login.bgClearSuccess')
})
state.hasBg = false
state.assetTimestamp = new Date().toISOString()
} catch (err) {
notify({
type: 'negative',
message: t('admin.login.bgClearFailed'),
caption: err.message
})
}
state.loading--
}
// MOUNTED
@ -366,6 +442,13 @@ onMounted(() => {
</script>
<style lang="scss">
.admin-login-bg {
width: 100%;
height: 140px;
object-fit: cover;
border-radius: 5px;
}
.admin-login-providers {
.w-item {
border-radius: 5px;

@ -97,6 +97,13 @@
:label="t(`common.newpage.goback`)"
@click="goBack" />
</div>
<!--
A redirection, which is a page with nowhere to read: it takes the reader on rather than
showing them anything. Ahead of the article because there is no article -- see
`PageRedirect.vue` -- and behind the two screens above because a page that is locked, or
that is not there at all, has no target to have been given yet.
-->
<page-redirect v-else-if="pageStore.editor === `redirect`" />
<w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<div class="page-container-body p-4">
<!--
@ -297,6 +304,7 @@ import FooterNav from '@/components/FooterNav.vue'
import LoadingGeneric from '@/components/LoadingGeneric.vue'
import PageActionsCol from '@/components/PageActionsCol.vue'
import PageHeader from '@/components/PageHeader.vue'
import PageRedirect from '@/components/PageRedirect.vue'
import PageTags from '@/components/PageTags.vue'
import PageToc from '@/components/PageToc.vue'
import PageUnlockDialog from '@/components/PageUnlockDialog.vue'
@ -306,6 +314,10 @@ const editorComponents = {
markdown: defineAsyncComponent({
loader: () => import('../components/EditorMarkdown.vue'),
loadingComponent: LoadingGeneric
}),
redirect: defineAsyncComponent({
loader: () => import('../components/EditorRedirect.vue'),
loadingComponent: LoadingGeneric
})
// wysiwyg: defineAsyncComponent({
// loader: () => import('../components/EditorWysiwyg.vue'),
@ -364,7 +376,9 @@ const showSidebar = computed(() => {
siteStore.theme.tocPosition !== 'off' &&
!editorStore.isActive &&
// -> Contents, tags and a rating, all of a page that is not there
!pageStore.notFound
!pageStore.notFound &&
// -> Nor of one nobody stays on: a redirection has no headings to list and is gone in a moment
pageStore.editor !== 'redirect'
)
})
/*
@ -567,8 +581,18 @@ watch(
try {
await pageStore.pageLoad({ path: newValue })
if (editorStore.isActive) {
/*
Walking away from the editor closes it, and `mode` describes the editor that was open so
it has to go back with it. Left on `create`, it goes on claiming a page is being written
long after the reader has moved on to reading one, and everything that asks gets the wrong
answer: `pageSave` POSTs a new page instead of patching the one on screen, the header
offers Create Page where Save Changes belongs, and Discard throws away a property edit as
though it were an abandoned draft putting the welcome screen over a wiki that has a home
page.
*/
editorStore.$patch({
isActive: false
isActive: false,
mode: 'edit'
})
}
// -> Load Blocks. `?.` because a locked page draws its lock screen in place of the article, so
@ -687,9 +711,10 @@ function goBack() {
<style lang="scss">
/*
The column in place of the article: the lock screen, and the page that does not exist. Both are the
same shape -- a large faint icon, a sentence, and the one button that does something about it -- and
share the styling so they cannot drift apart.
The column in place of the article: the lock screen, the page that does not exist, and the
redirection on its way somewhere else. All three are the same shape -- a large faint icon, a
sentence, and the one button that does something about it -- and share the styling so they cannot
drift apart. `PageRedirect.vue` draws its own screens with these classes for that reason.
*/
.page-placeholder {
display: flex;

@ -6,7 +6,7 @@
<p class="text-grey-7">Login to continue</p>
<auth-login-panel />
</div>
<div class="auth-bg" aria-hidden="true"><img :src="`/_site/current/loginbg`" alt="" /></div>
<div class="auth-bg" aria-hidden="true"><img :src="`/_site/current/loginBg`" alt="" /></div>
</div>
</template>

@ -115,6 +115,16 @@ export const usePageStore = defineStore('page', {
},
isHome: (state) => {
return ['', 'home'].includes(state.path)
},
/**
* Where to send someone who is leaving the editor on this page.
*
* Its own path, except for a redirection, which is held on arrival: whoever just wrote down where
* this page sends people is the one person who does not want to be sent there. `?redirect=no` is
* what holds it see `PageRedirect.vue` and the screen it lands on offers to follow it.
*/
editorExitPath: (state) => {
return `/${state.path}${state.editor === 'redirect' ? '?redirect=no' : ''}`
}
},
actions: {
@ -381,6 +391,12 @@ export const usePageStore = defineStore('page', {
id: 0,
locale: locale || this.locale,
path: newPath,
/*
The editor is a field of the page being written, not just of the editor holding it: anything
asking what KIND of page is on screen reads it here. Left unset, the store kept the last
page's answer -- so opening a new page from a redirection said it was one too.
*/
editor,
title: title ?? '',
description: description ?? '',
icon: DEFAULT_PAGE_ICON,
@ -393,7 +409,13 @@ export const usePageStore = defineStore('page', {
contentLoaded: true,
render: '',
isBrowsable: true,
isSearchable: true,
/*
A redirection is browsable like any other page and findable in none: a search result for one
would stand in front of the page the reader actually wanted. The server settles this either
way -- see `createPage` in `models/pages.ts` -- so this is the store agreeing with it rather
than deciding it.
*/
isSearchable: editor !== 'redirect',
// -> The page being created is very often the one that was missing, and it is not missing now
notFound: false,
mode: 'edit'
@ -689,7 +711,13 @@ export const usePageStore = defineStore('page', {
if (editorStore.mode === 'create') {
editorStore.$patch({ mode: 'edit' })
this.router.replace(`/${this.path}`)
/*
Awaited, because the caller closes the editor the moment this resolves. An unawaited
navigation leaves one render of the page view at the route the EDITOR was on -- which for
a redirection is a page that reads its own query to decide whether to follow itself, sees
the editor's route, and takes its author to the target they just typed in.
*/
await this.router.replace(this.editorExitPath)
}
// Update editor state timestamps
@ -707,7 +735,8 @@ export const usePageStore = defineStore('page', {
async cancelPageEdit() {
const editorStore = useEditorStore()
await this.pageLoad({ id: editorStore.originPageId ? editorStore.originPageId : this.id })
this.router.replace(`/${this.path}`)
// -> Awaited for the same reason as in `pageSave`: the editor closes when this resolves
await this.router.replace(this.editorExitPath)
},
generateToc() {}
}

@ -51,6 +51,12 @@ export const useSiteStore = defineStore('site', {
title: '',
description: '',
logoText: true,
/**
* The extensions this site's content is written in, lowercase and without the dot. A path ending
* in one of them addresses the page underneath it `/foo/bar.md` is `/foo/bar` which the
* router acts on for links inside pages and the server acts on for requests that reach it.
*/
pageExtensions: [],
search: '',
searchLastQuery: '',
searchIsLoading: false,
@ -181,6 +187,7 @@ export const useSiteStore = defineStore('site', {
title: siteInfo.title,
description: siteInfo.description,
logoText: siteInfo.logoText,
pageExtensions: siteInfo.pageExtensions ?? [],
company: siteInfo.company,
contentLicense: siteInfo.contentLicense,
footerExtra: siteInfo.footerExtra,

Loading…
Cancel
Save