diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 5bfc8d14f..9a2c40464 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -218,6 +218,8 @@ async function routes(app: FastifyInstance) { tags?: string editor?: string publishState?: string + creatorId?: string + authorId?: string orderBy?: SearchOrderBy orderByDirection?: 'asc' | 'desc' offset?: number @@ -229,7 +231,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Search pages', description: - 'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\nA password-protected page is listed like any other — its title and description are not what the password covers — but for a searcher who would have to enter that password it can only be matched on those two, never on the text behind the lock, and it comes back with no `highlight`.\n\n`highlight` is an excerpt with the matched terms wrapped in ``, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.', + 'Postgres full-text search over the pages of a site, ranked by relevance. `query` may be left out, in which case the filters alone decide the results — which is what a search for nothing but tags is.\n\nReadable without a session, for the same reason reading a page is: an anonymous request only matches published pages. Drafts are included only for someone who may write pages. A page marked as not searchable never appears, whoever is asking.\n\nA password-protected page is listed like any other — its title and description are not what the password covers — but for a searcher who would have to enter that password it can only be matched on those two, never on the text behind the lock, and it comes back with no `highlight`.\n\n`creatorId` and `authorId` narrow the results to the pages of one person — who wrote a page and who touched it last are two different questions, and a public user profile asks both. They are a filter like any other, so a page marked as not searchable stays out of them too.\n\n`highlight` is an excerpt with the matched terms wrapped in ``, and is the only field carrying markup — the excerpt is escaped before those are added. It is absent unless term highlighting is enabled in the search settings.', tags: ['Pages'], params: siteIdParam, querystring: { @@ -263,6 +265,16 @@ async function routes(app: FastifyInstance) { type: 'string', enum: ['draft', 'published', 'scheduled'] }, + creatorId: { + type: 'string', + format: 'uuid', + description: 'Only pages this user created.' + }, + authorId: { + type: 'string', + format: 'uuid', + description: 'Only pages this user edited last.' + }, orderBy: { type: 'string', enum: SEARCH_ORDER_BY, @@ -303,6 +315,7 @@ async function routes(app: FastifyInstance) { description: { type: ['string', 'null'] }, icon: { type: ['string', 'null'] }, tags: { type: 'array', items: { type: 'string' } }, + createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' }, relevancy: { type: 'number' }, highlight: { @@ -332,6 +345,8 @@ async function routes(app: FastifyInstance) { tags: splitList(req.query.tags), editor: req.query.editor, publishState: req.query.publishState, + creatorId: req.query.creatorId, + authorId: req.query.authorId, orderBy: req.query.orderBy, orderByDirection: req.query.orderByDirection, offset: req.query.offset, diff --git a/backend/api/schemas/user.ts b/backend/api/schemas/user.ts index 083734e41..9a28880bb 100644 --- a/backend/api/schemas/user.ts +++ b/backend/api/schemas/user.ts @@ -163,6 +163,50 @@ export async function registerSchemas(app: FastifyInstance): Promise { } }) + /** + * PUBLIC USER PROFILE - What a user's profile page shows anyone who opens it + * + * Everything here is either something the user typed into their own profile to be seen, or the fact + * that they were here. The email is absent on purpose — see `PublicUserProfile` in the model. + */ + app.addSchema({ + $id: 'PublicUserProfile', + type: 'object', + properties: { + id: { + type: 'string', + format: 'uuid' + }, + name: { + type: 'string' + }, + hasAvatar: { + type: 'boolean' + }, + location: { + type: 'string' + }, + jobTitle: { + type: 'string' + }, + pronouns: { + type: 'string' + }, + timezone: { + type: 'string', + description: + 'IANA time zone name, or an empty string for a user who never picked one — in which case there is no local time to show for them.' + }, + lastLoginAt: { + // -> `nullable` rather than a type array, as on `UserCore`: the emitted spec is OpenAPI 3.0 + type: 'string', + nullable: true, + format: 'date-time', + description: 'RFC 3339 Date Time, or null if the user has never logged in' + } + } + }) + /** * USER PROFILE UPDATE - The fields a user may change on its own profile * diff --git a/backend/api/users.ts b/backend/api/users.ts index be1f157af..5e1d340f4 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -1225,6 +1225,56 @@ async function routes(app: FastifyInstance) { } ) + /** + * PUBLIC USER PROFILE + */ + app.get<{ Params: { userId: string } }>( + '/:userId/profile', + { + /* + No route-level permissions: this is the public profile page, readable by whoever can read the + wiki — the same reach an avatar already has under `/_user//avatar`, and for the same reason. + A page names its author, so a reader who can open the page can look them up. + + Nothing here enumerates users either: the ID has to be known, and `GET /users` — which is what + turns a wiki into a list of names and addresses — still wants `read:users`. + */ + schema: { + summary: 'Get a user public profile', + description: + 'What a user chose to say about themselves, plus when they were last here. Answers 404 for a system account — the guest an anonymous reader is, and the account content nobody authored is attributed to, are not people and have no profile.', + tags: ['Users'], + params: { + type: 'object', + properties: { + userId: { + type: 'string', + format: 'uuid' + } + }, + required: ['userId'] + }, + response: { + 200: { + description: 'Public user profile', + type: 'object', + $ref: 'PublicUserProfile#' + } + } + } + }, + async (req, reply) => { + const profile = await WIKI.models.users.getPublicProfile(req.params.userId) + if (!profile) { + return reply.notFound('User does not exist.') + } + return profile + } + ) + + /** + * GET USER + */ app.get<{ Params: { userId: string } }>( '/:userId', { diff --git a/backend/index.ts b/backend/index.ts index ce2b64f5c..021c14751 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -53,6 +53,9 @@ const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'] * and `/_error` too, and those have to reach the app shell like any page path. The distinction the * shell needs is "does something here serve this", which is this list, and it has to be kept in step * with the registrations below. + * + * `_user` is registered below but absent here: it is shared with the frontend router, and `isServerUrl` + * is what splits it. */ const SERVER_ROUTE_SEGMENTS = new Set([ '_api', @@ -64,10 +67,30 @@ const SERVER_ROUTE_SEGMENTS = new Set([ '_render', '_site', '_terminal', - '_thumb', - '_user' + '_thumb' ]) +/** + * Whether the server answers this URL, as opposed to the app shell being handed over for the frontend + * router to resolve. + * + * `_user` is the one segment the two SHARE, so it cannot be settled by its first segment alone: the + * server serves avatars at `/_user//avatar`, while the frontend owns the public profile page at + * `/_user/`. Only the avatar is the server's, and everything else under there is the app's — so a + * mistyped avatar URL hands back a profile page that says the user does not exist, which is the same + * answer by a different route. + * + * `frontend/vite.config.js` draws the same line from the other side: its dev proxy forwards only the + * avatar path to this server, and the two have to agree. + */ +function isServerUrl(urlPath: string): boolean { + const segments = urlPath.split('/') + if (segments[1] === '_user') { + return segments[3] === 'avatar' + } + return SERVER_ROUTE_SEGMENTS.has(segments[1] ?? '') +} + /** * Whether a URL addresses the page tree rather than the server itself. * @@ -727,7 +750,7 @@ async function initHTTPServer() { app.setNotFoundHandler(async (req, reply) => { const urlPath = req.raw.url!.split('?')[0]! const firstSegment = urlPath.split('/')[1] ?? '' - const isSystemPath = SERVER_ROUTE_SEGMENTS.has(firstSegment) + const isSystemPath = isServerUrl(urlPath) const isReservedRootFile = RESERVED_ROOT_FILES.has(firstSegment.toLowerCase()) // -> HEAD as well as GET: it has to answer what GET would, or a monitor pointed at the wiki reads a // 404 for a page the browser beside it loads. Node drops the body for HEAD on its own. diff --git a/backend/locales/en.json b/backend/locales/en.json index 257c29cf9..280cef032 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -2416,6 +2416,18 @@ "tags.searchWithinResultsPlaceholder": "Search within results...", "tags.selectOneMoreTags": "Select one or more tags", "tags.selectOneMoreTagsHint": "Select one or more tags on the left.", + "userProfile.lastLogin": "Last Login", + "userProfile.loadMore": "Load More", + "userProfile.loadingFailed": "Failed to load user profile.", + "userProfile.localTime": "Local Time", + "userProfile.location": "Location", + "userProfile.noPagesCreated": "This user hasn't created any page yet.", + "userProfile.noPagesUpdated": "This user hasn't modified any page yet.", + "userProfile.notFound": "There is no such user.", + "userProfile.pagesCreated": "Pages Created", + "userProfile.pagesLoadingFailed": "Failed to load pages.", + "userProfile.pagesUpdated": "Last Modified Pages", + "userProfile.title": "User Profile", "welcome.admin": "Administration Area", "welcome.createHome": "Create the homepage", "welcome.homeDefault.content": "Write some content here...", diff --git a/backend/models/search.ts b/backend/models/search.ts index 33affcb14..dcc3b07e6 100644 --- a/backend/models/search.ts +++ b/backend/models/search.ts @@ -54,7 +54,7 @@ export interface RebuildResult { locales: { locale: string; dictionary: string; pages: number }[] } -export const SEARCH_ORDER_BY = ['relevancy', 'title', 'updatedAt'] as const +export const SEARCH_ORDER_BY = ['relevancy', 'title', 'createdAt', 'updatedAt'] as const export type SearchOrderBy = (typeof SEARCH_ORDER_BY)[number] export interface SearchResult { @@ -65,6 +65,7 @@ export interface SearchResult { description: string | null icon: string | null tags: string[] + createdAt: string updatedAt: string relevancy: number highlight: string | null @@ -83,6 +84,15 @@ export interface SearchPagesParams { tags?: string[] editor?: string publishState?: string + /** + * Only pages this user created, and only pages this user edited last. + * + * Two separate columns rather than one "by this person": a page is created once and edited by + * whoever touched it most recently, and the profile page asks both questions of the same user in + * two different tabs. + */ + creatorId?: string + authorId?: string orderBy?: SearchOrderBy orderByDirection?: 'asc' | 'desc' offset?: number @@ -221,6 +231,8 @@ class Search { tags = [], editor = '', publishState = '', + creatorId = '', + authorId = '', orderBy = 'relevancy', orderByDirection = 'desc', offset = 0, @@ -293,6 +305,12 @@ class Search { if (editor) { conditions.push(sql`p.editor = ${editor}`) } + if (creatorId) { + conditions.push(sql`p."creatorId" = ${creatorId}`) + } + if (authorId) { + conditions.push(sql`p."authorId" = ${authorId}`) + } const direction = orderByDirection === 'asc' ? sql`ASC` : sql`DESC` // -> Every page ranks 0 without a query, which would leave the order down to the planner @@ -300,6 +318,7 @@ class Search { const ordering = { relevancy: sql`relevancy ${direction}, p."updatedAt" DESC`, title: sql`p.title ${direction}`, + createdAt: sql`p."createdAt" ${direction}`, updatedAt: sql`p."updatedAt" ${direction}` }[effectiveOrderBy] @@ -327,6 +346,7 @@ class Search { p.description, p.icon, p.tags, + to_char(p."createdAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "createdAt", to_char(p."updatedAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "updatedAt", ${hasQuery ? sql`ts_rank(p.ts, ${tsQuery})` : sql`0`} AS relevancy, ${highlight} AS highlight, @@ -360,6 +380,7 @@ class Search { description: row.description ?? null, icon: row.icon ?? null, tags: (row.tags ?? []) as string[], + createdAt: row.createdAt as string, updatedAt: row.updatedAt as string, relevancy: Number(row.relevancy ?? 0), // -> Escaped first, so the only markup that survives is the emphasis postgres marked diff --git a/backend/models/users.ts b/backend/models/users.ts index 15e824d30..6dd41873a 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -109,6 +109,26 @@ export interface UserProfile { cvd: string } +/** + * What a user's public profile page shows to whoever opens it. + * + * A strict subset of `UserProfile`, and deliberately not built by trimming one: the email is absent + * because a profile page is readable by anyone who can read the wiki, and every preference that only + * decides how the wiki is DRAWN for its owner — date format, appearance, colour vision — says nothing + * about the person. The time zone is the one preference that does, since it is what the card needs to + * say what time it is where they are. + */ +export interface PublicUserProfile { + id: string + name: string + hasAvatar: boolean + location: string + jobTitle: string + pronouns: string + timezone: string + lastLoginAt: Date | null +} + /** The fields a user may change on its own profile. Notably not the email, nor any admin flag. */ export interface UserProfilePatch { name?: string @@ -555,6 +575,37 @@ class Users { } } + /** + * A user as their public profile page presents them, which is anybody who can read the wiki. + * + * `meta` and `prefs` are defaulted here for the same reason `getProfile` defaults them: they are + * free-form blobs, and a user created before a key existed simply has none. + * + * @returns The profile, or null when there is no person behind the ID — no such user, or a system + * account, which is what the guest every anonymous reader is and what content nobody + * authored is attributed to. Neither has a profile to show. + */ + async getPublicProfile(id: string): Promise { + const user = await this.getById(id) + if (!user || user.isSystem) { + return null + } + const meta = (user.meta ?? {}) as Record + const prefs = (user.prefs ?? {}) as Record + return { + id: user.id, + name: user.name, + hasAvatar: user.hasAvatar, + location: meta.location ?? '', + jobTitle: meta.jobTitle ?? '', + pronouns: meta.pronouns ?? '', + // -> Empty for a user who never picked one, which leaves the card with no local time to show + // rather than one from a zone nobody chose + timezone: prefs.timezone ?? '', + lastLoginAt: user.lastLoginAt + } + } + /** * A user's own settings for one editor. * diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index dc6c5b5c5..7e9030cdf 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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. - 272 icons. + 274 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -91,6 +91,7 @@ export const BUNDLED_ICONS = { "la:lock": {"body":"","width":32,"height":32}, "la:lock-open": {"body":"","width":32,"height":32}, "la:magic": {"body":"","width":32,"height":32}, + "la:map-marker": {"body":"","width":32,"height":32}, "la:microchip": {"body":"","width":32,"height":32}, "la:minus": {"body":"","width":32,"height":32}, "la:mountain": {"body":"","width":32,"height":32}, @@ -149,6 +150,7 @@ export const BUNDLED_ICONS = { "la:user-friends": {"body":"","width":32,"height":32}, "la:user-minus": {"body":"","width":32,"height":32}, "la:user-plus": {"body":"","width":32,"height":32}, + "la:user-slash": {"body":"","width":32,"height":32}, "la:users": {"body":"","width":32,"height":32}, "la:window-close": {"body":"","width":32,"height":32}, "mdi:account-edit": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/UserEditOverlay.vue b/frontend/src/components/UserEditOverlay.vue index 80e752e67..19c933270 100644 --- a/frontend/src/components/UserEditOverlay.vue +++ b/frontend/src/components/UserEditOverlay.vue @@ -53,6 +53,21 @@ {{ sc.text }} + + diff --git a/frontend/src/helpers/serverPaths.js b/frontend/src/helpers/serverPaths.js index 71bb3fb3c..1edd15ae2 100644 --- a/frontend/src/helpers/serverPaths.js +++ b/frontend/src/helpers/serverPaths.js @@ -1,6 +1,6 @@ /** * Paths the server owns rather than the page tree: build assets, the API, block bundles, uploaded - * files, icons, per-site files, thumbnails and avatars. + * files, icons, per-site files and thumbnails. Avatars are the exception -- see `isServerPath`. * * One list, because two different things ask the same question of a URL and must not drift apart: * which links the router should keep its hands off (`renderedContent.js`), and which image sources @@ -13,11 +13,20 @@ export const SERVER_PATHS = [ '/_files/', '/_icons/', '/_site/', - '/_thumb/', - '/_user/' + '/_thumb/' ] -/** Whether a root-relative path is one of them. */ +/** + * Whether a root-relative path is one of them. + * + * `/_user/` is the one prefix the server shares with the router, so it is not in the list above: the + * server serves avatars at `/_user//avatar`, while the app owns the public profile page at + * `/_user/`. A link to somebody's profile is therefore a link the router follows, and only the + * avatar underneath it is a file. `backend/index.ts` splits the same segment the same way. + */ export function isServerPath(path) { + if (path.startsWith('/_user/')) { + return path.endsWith('/avatar') + } return SERVER_PATHS.some((prefix) => path.startsWith(prefix)) } diff --git a/frontend/src/layouts/ProfileLayout.vue b/frontend/src/layouts/ProfileLayout.vue index c5ed533ee..ec8b07b00 100644 --- a/frontend/src/layouts/ProfileLayout.vue +++ b/frontend/src/layouts/ProfileLayout.vue @@ -42,17 +42,15 @@ - + + + + + + + {{ t('profile.viewPublicProfile') }} + + diff --git a/frontend/src/pages/UserProfile.vue b/frontend/src/pages/UserProfile.vue new file mode 100644 index 000000000..12afef58b --- /dev/null +++ b/frontend/src/pages/UserProfile.vue @@ -0,0 +1,567 @@ + + + + + diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index 9cd68abe9..e4972cd29 100644 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -49,6 +49,15 @@ const routes = [ path: '/_search', component: () => import('@/pages/Search.vue') }, + /* + The public profile of one user. `/_user` is shared with the server, which serves avatars at + `/_user//avatar` -- both `backend/index.ts` and the dev proxy in `frontend/vite.config.js` + split the segment the same way, so this route only ever sees the profile half. + */ + { + path: '/_user/:userId', + component: () => import('@/pages/UserProfile.vue') + }, { path: '/_admin', component: () => import('@/layouts/AdminLayout.vue'), diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e2784b04e..915c1eeef 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -211,17 +211,24 @@ export default defineConfig(({ mode }) => { allowedHosts: true, port: userConfig.dev?.port, proxy: [ - '_api', - '_blocks', - '_collab', - '_files', - '_icons', - '_site', - '_terminal', - '_thumb', - '_user' + '/_api', + '/_blocks', + '/_collab', + '/_files', + '/_icons', + '/_site', + '/_terminal', + '/_thumb', + /* + Not `/_user`: that segment is shared. The backend serves avatars under it, while the app's + own router owns the public profile page at `/_user/` -- which has to be served by THIS + dev server, or it would come back as the built shell from `assets/` and boot yesterday's + bundle. A key starting with `^` is a regular expression to Vite, which is how the two are + told apart. `backend/index.ts` draws the same line from the other side. + */ + '^/_user/[^/]+/avatar' ].reduce((result, key) => { - result[`/${key}`] = { + result[key] = { target: { host: '127.0.0.1', port: userConfig.port