From 4d4c3cd167e6f495fd8cf1a8cce9c62e20e260db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:20:07 +0000 Subject: [PATCH 1/9] =?UTF-8?q?feat(content):=20=F0=9F=94=92=20access=20ru?= =?UTF-8?q?les=20for=20containers=20and=20Blog=20categories=20(work=20in?= =?UTF-8?q?=20progress)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core access model (view, read, comment, download attachments), per-scope rules with inheritance and ancestor restrictions, enforcement in public Content Engine reads and writes, permission-aware search filtering and verification, Forum and Blog wiring, and the AdminCP access editor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CnAiFc4gJkRmXr1TwfkSNX --- .../vitnode/src/api/lib/search-filters.ts | 7 + packages/vitnode/src/api/models/events.ts | 9 + packages/vitnode/src/api/models/search.ts | 234 +++++++-- .../admin/users/routes/timeline.route.ts | 3 + packages/vitnode/src/content/access/const.ts | 42 ++ packages/vitnode/src/content/access/policy.ts | 456 ++++++++++++++++ .../vitnode/src/content/access/schemas.ts | 117 +++++ .../src/content/access/server/actor.ts | 107 ++++ .../src/content/access/server/admin-routes.ts | 418 +++++++++++++++ .../src/content/access/server/attachments.ts | 16 + .../src/content/access/server/decide.ts | 246 +++++++++ .../src/content/access/server/families.ts | 296 +++++++++++ .../vitnode/src/content/access/server/http.ts | 80 +++ .../src/content/access/server/invalidate.ts | 79 +++ .../src/content/access/server/report.ts | 112 ++++ .../src/content/access/server/rules.ts | 231 +++++++++ .../src/content/access/server/search.ts | 178 +++++++ .../src/content/access/server/source.ts | 488 ++++++++++++++++++ packages/vitnode/src/content/cache.ts | 8 + packages/vitnode/src/content/const.ts | 2 + .../vitnode/src/content/containers/define.ts | 25 + .../content/containers/server/admin-routes.ts | 22 +- .../containers/server/public-routes.ts | 127 ++++- .../src/content/containers/server/service.ts | 8 + .../vitnode/src/content/containers/types.ts | 3 + packages/vitnode/src/content/define-access.ts | 86 +++ packages/vitnode/src/content/define.ts | 14 + packages/vitnode/src/content/errors.ts | 8 +- packages/vitnode/src/content/index.ts | 31 ++ .../src/content/server/delivery-routes.ts | 37 +- .../src/content/server/delivery-sitemap.ts | 7 + packages/vitnode/src/content/server/index.ts | 44 ++ .../server/localized-public-service.ts | 14 +- .../src/content/server/public-permissions.ts | 35 +- .../src/content/server/public-routes.ts | 172 +++++- .../src/content/server/public-service.ts | 33 +- .../server/public-visibility-routes.ts | 20 +- .../content/server/public-write-service.ts | 52 +- packages/vitnode/src/content/server/routes.ts | 23 + packages/vitnode/src/content/types.ts | 3 + .../vitnode/src/database/content-access.ts | 58 +++ packages/vitnode/src/database/files.ts | 7 + packages/vitnode/src/database/relations.ts | 2 + packages/vitnode/src/database/search.ts | 11 + packages/vitnode/src/locales/en.json | 98 ++++ .../src/tanstack/admin/containers/route.tsx | 1 + .../tanstack/admin/content/row-actions.tsx | 29 ++ .../tanstack/comments/content-comments.tsx | 3 + .../tanstack/comments/use-content-comments.ts | 16 +- .../src/tanstack/content/access-required.tsx | 112 ++++ .../vitnode/src/tanstack/content/index.ts | 5 + .../src/tanstack/fetcher/fetcher.test-d.ts | 4 +- .../views/admin/views/access/access-form.tsx | 479 +++++++++++++++++ .../admin/views/access/access-request.ts | 145 ++++++ .../views/admin/views/access/access-sheet.tsx | 64 +++ .../views/containers/container-manager.tsx | 39 +- .../admin/views/containers/container-row.tsx | 16 +- .../content/actions/row-actions-model.ts | 11 + .../admin/views/content/content-labels.ts | 1 + .../modules/categories/categories.module.ts | 109 +++- .../src/api/modules/posts/posts.module.ts | 7 + plugins/blog/src/content/category.ts | 5 + plugins/blog/src/content/post.ts | 4 + plugins/blog/src/locales/en.json | 3 +- plugins/blog/src/pages/blog-category-page.tsx | 3 + plugins/blog/src/pages/blog-index.tsx | 14 +- plugins/blog/src/pages/post-comments-data.ts | 5 +- plugins/blog/src/pages/post-comments.tsx | 5 +- plugins/blog/src/pages/post-page.tsx | 72 ++- plugins/blog/src/pages/post-query.ts | 2 + .../src/api/modules/forum/stats.routes.ts | 87 +++- .../src/api/modules/forum/topics.routes.ts | 41 +- plugins/forum/src/content/forums.ts | 5 + plugins/forum/src/locales/en.json | 4 +- plugins/forum/src/pages/forum-list.tsx | 12 + plugins/forum/src/pages/forum-page.tsx | 54 +- plugins/forum/src/pages/thread.tsx | 15 +- plugins/forum/src/pages/topic-page.tsx | 94 +++- 78 files changed, 5341 insertions(+), 194 deletions(-) create mode 100644 packages/vitnode/src/content/access/const.ts create mode 100644 packages/vitnode/src/content/access/policy.ts create mode 100644 packages/vitnode/src/content/access/schemas.ts create mode 100644 packages/vitnode/src/content/access/server/actor.ts create mode 100644 packages/vitnode/src/content/access/server/admin-routes.ts create mode 100644 packages/vitnode/src/content/access/server/attachments.ts create mode 100644 packages/vitnode/src/content/access/server/decide.ts create mode 100644 packages/vitnode/src/content/access/server/families.ts create mode 100644 packages/vitnode/src/content/access/server/http.ts create mode 100644 packages/vitnode/src/content/access/server/invalidate.ts create mode 100644 packages/vitnode/src/content/access/server/report.ts create mode 100644 packages/vitnode/src/content/access/server/rules.ts create mode 100644 packages/vitnode/src/content/access/server/search.ts create mode 100644 packages/vitnode/src/content/access/server/source.ts create mode 100644 packages/vitnode/src/content/define-access.ts create mode 100644 packages/vitnode/src/database/content-access.ts create mode 100644 packages/vitnode/src/tanstack/content/access-required.tsx create mode 100644 packages/vitnode/src/views/admin/views/access/access-form.tsx create mode 100644 packages/vitnode/src/views/admin/views/access/access-request.ts create mode 100644 packages/vitnode/src/views/admin/views/access/access-sheet.tsx diff --git a/packages/vitnode/src/api/lib/search-filters.ts b/packages/vitnode/src/api/lib/search-filters.ts index eb4e22a884..83bc260cb7 100644 --- a/packages/vitnode/src/api/lib/search-filters.ts +++ b/packages/vitnode/src/api/lib/search-filters.ts @@ -125,6 +125,13 @@ export const searchIndexFilters = ( } const visible = visibleCondition(core_search_index, params); if (visible) conditions.push(visible); + // Every scope the item is filed in has to be one the viewer may read. An + // empty list (nothing readable) still keeps items filed in no scope. + if (params.accessScopes) { + conditions.push( + sql`${core_search_index.accessScopes} <@ ${sql.param([...params.accessScopes])}::text[]`, + ); + } return conditions.length ? and(...conditions) : undefined; }; diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts index 40d54d76ce..65f06552e1 100644 --- a/packages/vitnode/src/api/models/events.ts +++ b/packages/vitnode/src/api/models/events.ts @@ -66,6 +66,15 @@ export interface VitNodeEvents { runId: number; userId: null | number; }; + "content.access.updated": { + /** The actions whose rule this scope now sets, inherits or changed. */ + changedActions: string[]; + /** The container collection or scope content type id. */ + family: string; + pluginId: string; + scopeId: string; + userId: null | number; + }; "content.approval.approved": ContentApprovalEventPayload & { reviewerId: number; }; diff --git a/packages/vitnode/src/api/models/search.ts b/packages/vitnode/src/api/models/search.ts index f1a9f86c37..a0e2137c9b 100644 --- a/packages/vitnode/src/api/models/search.ts +++ b/packages/vitnode/src/api/models/search.ts @@ -27,6 +27,11 @@ import { canonicalSearchSuggestions, } from "@/api/lib/search-suggestions"; import { storageUrlOf } from "@/api/lib/storage-url"; +import { + contentSearchAllowedScopes, + verifyContentSearchHits, + withContentSearchAccessScopes, +} from "@/content/access/server/search"; import { contentIdFromKey, contentIdKey } from "@/content/ids"; import { contentLocalesMatch } from "@/content/locale"; import { core_files } from "@/database/files"; @@ -49,6 +54,12 @@ const BULK_UPSERT_CHUNK = 500; * it to {@link SearchModel}. */ export interface SearchDocument { + /** + * The access scopes the item is filed in, as `family:scopeId`. Filled in by + * {@link SearchModel} from the content tables for every access-controlled + * content type - whatever a builder passes is replaced. + */ + accessScopes?: readonly string[]; /** Users credited for the item, for author filters and author hits. */ authorIds?: readonly number[]; /** The forum, category or other container holding the item. */ @@ -89,6 +100,12 @@ export interface SearchDocument { /** A search query: filters, sorting and cursor pagination. */ export interface SearchQueryParams { + /** + * The scope tokens the viewer may read: a document is found only when every + * one of its `accessScopes` is listed. `null` or absent is no access filter. + * {@link SearchModel} fills it in for the request's viewer. + */ + accessScopes?: null | readonly string[]; authorId?: number; authorIds?: readonly number[]; containerId?: number; @@ -100,6 +117,11 @@ export interface SearchQueryParams { hiddenAccess?: SearchHiddenAccess; /** Skips every visibility check - for admin and diagnostic reads only. */ includePrivate?: boolean; + /** + * Skips the access rules of content scopes as well - only for staff routes + * that already checked an AdminCP permission. Defaults to `false`. + */ + skipAccess?: boolean; itemTypes?: string[]; // Restrict results to one locale (the viewer's). Rows with an empty // `languageCode` (language-agnostic content) always match. @@ -401,6 +423,7 @@ const toRow = (doc: SearchDocument) => ({ content: doc.content, containerType: doc.containerType ?? null, containerId: doc.containerId ?? null, + accessScopes: [...new Set(doc.accessScopes ?? [])], url: doc.url ?? null, isPublic: doc.isPublic ?? true, isHidden: doc.isHidden ?? false, @@ -604,6 +627,7 @@ export class SearchModel { content: sql`excluded."content"`, containerType: sql`excluded."containerType"`, containerId: sql`excluded."containerId"`, + accessScopes: sql`excluded."accessScopes"`, url: sql`excluded."url"`, isPublic: sql`excluded."isPublic"`, isHidden: sql`excluded."isHidden"`, @@ -630,6 +654,86 @@ export class SearchModel { : params; } + /** The viewer's access filter, unless the caller is a staff route that skips it. */ + private async withAccess( + params: SearchQueryParams, + ): Promise { + if (params.skipAccess) return { ...params, accessScopes: null }; + + return { + ...params, + accessScopes: await contentSearchAllowedScopes(this.c), + }; + } + + /** + * Re-stamps the access scopes of indexed items from the content tables, in + * the canonical table and the provider - after a search found an item whose + * stored scopes no longer match, or from the reconciliation job. + */ + async refreshAccessScopes( + items: readonly { itemId: ContentId; itemType: string }[], + ): Promise { + if (items.length === 0) return 0; + + const db = this.c.get("db"); + const byType = new Map(); + for (const item of items) { + byType.set(item.itemType, [ + ...(byType.get(item.itemType) ?? []), + contentIdKey(item.itemId), + ]); + } + + let changed = 0; + for (const [itemType, keys] of byType) { + const rows = await db + .select() + .from(core_search_index) + .where( + and( + eq(core_search_index.itemType, itemType), + inArray(core_search_index.itemId, [...new Set(keys)]), + ), + ); + if (rows.length === 0) continue; + + const contentTypes = this.c.get("core")?.contentTypes ?? []; + const docs: SearchDocument[] = rows.map(row => ({ + accessScopes: row.accessScopes, + authorIds: row.authorIds, + containerId: row.containerId ?? undefined, + containerType: row.containerType ?? undefined, + content: row.content, + createdAt: row.createdAt, + imageId: row.imageId, + isHidden: row.isHidden, + isPublic: row.isPublic, + itemId: searchItemIdFromKey(contentTypes, row.itemType, row.itemId), + itemType: row.itemType, + languageCode: row.languageCode, + metadata: row.metadata, + pluginId: row.pluginId, + title: row.title, + updatedAt: row.updatedAt ?? undefined, + url: row.url ?? undefined, + })); + const stamped = await withContentSearchAccessScopes(this.c, docs); + const stale = stamped.filter( + (doc, index) => + JSON.stringify([...(doc.accessScopes ?? [])].sort()) !== + JSON.stringify([...(rows[index]?.accessScopes ?? [])].sort()), + ); + if (stale.length === 0) continue; + + changed += stale.length; + await this.upsertRows(stale); + await this.provider().bulkIndex(this.c, stale); + } + + return changed; + } + private async withSuggestionImages( suggestions: SearchSuggestion[], ): Promise { @@ -646,9 +750,15 @@ export class SearchModel { term?: string; }): Promise { const db = this.c.get("db"); + // Only authors credited on what this viewer may read: a member who only + // writes in a restricted scope is not listed for anybody outside it. + const allowedScopes = await contentSearchAllowedScopes(this.c); const listed = and( eq(core_search_index.isPublic, true), eq(core_search_index.isHidden, false), + allowedScopes + ? sql`${core_search_index.accessScopes} <@ ${sql.param(allowedScopes)}::text[]` + : undefined, ); const columns = { id: core_users.id, @@ -697,12 +807,7 @@ export class SearchModel { return rows.map(toAuthor); } - const topAuthorIds = await this.c - .get("cache") - .remember( - `search:top-authors:${limit}`, - SEARCH_TOP_AUTHORS_TTL_SECONDS, - async () => { + const loadTopAuthors = async () => { const activity = db .select({ authorId: sql`unnest(${core_search_index.authorIds})`.as( @@ -720,8 +825,18 @@ export class SearchModel { .limit(limit); return top.map(row => row.authorId); - }, - ); + }; + // The shared cache only holds the list everybody gets; a restricted + // viewer's own list is read fresh. + const topAuthorIds = allowedScopes + ? await loadTopAuthors() + : await this.c + .get("cache") + .remember( + `search:top-authors:${limit}`, + SEARCH_TOP_AUTHORS_TTL_SECONDS, + loadTopAuthors, + ); if (topAuthorIds.length === 0) return []; const rows = await db @@ -740,8 +855,11 @@ export class SearchModel { /** {@link SearchModel.index} for many documents at once. */ async bulkIndex(docs: SearchDocument[]): Promise { - const clean = docs.map(doc => - this.resolveOwner({ ...doc, content: stripHtml(doc.content) }), + const clean = await withContentSearchAccessScopes( + this.c, + docs.map(doc => + this.resolveOwner({ ...doc, content: stripHtml(doc.content) }), + ), ); await this.upsertRows(clean); @@ -764,7 +882,10 @@ export class SearchModel { const provider = this.provider(); if (!provider.countByType) return null; - return await provider.countByType(this.c, this.withFallbacks(params)); + return await provider.countByType( + this.c, + this.withFallbacks(await this.withAccess(params)), + ); } /** @@ -882,10 +1003,12 @@ export class SearchModel { * `core_search_index`; the provider mirrors it. */ async index(doc: SearchDocument): Promise { - const clean = this.resolveOwner({ - ...doc, - content: stripHtml(doc.content), - }); + const [clean] = await withContentSearchAccessScopes(this.c, [ + this.resolveOwner({ + ...doc, + content: stripHtml(doc.content), + }), + ]); const commentParent = await this.newCommentParent(clean); @@ -945,12 +1068,16 @@ export class SearchModel { languageCode?: string; limit: number; }): Promise { - return await this.withSuggestionImages( - await canonicalPopularSearchItems(this.c, { - limit, - params: this.withFallbacks({ includePrivate: false, languageCode }), - }), - ); + const params = await this.withAccess({ + includePrivate: false, + languageCode, + }); + const items = await canonicalPopularSearchItems(this.c, { + limit, + params: this.withFallbacks(params), + }); + + return await this.withSuggestionImages(await this.verified(items)); } async recordPopularity({ @@ -967,25 +1094,68 @@ export class SearchModel { /** Runs a query on the search adapter and attaches each hit's authors and image. */ async search(params: SearchQueryParams): Promise { + const scoped = await this.withAccess(params); const result = await this.provider().search( this.c, - this.withFallbacks(params), + this.withFallbacks(scoped), ); + // The index filtered by the scopes it remembers; the content tables decide. + // A hit that fails here was indexed before its record moved or a rule + // changed: it is never returned, its scopes are stamped again, and the + // total no longer counts it. Counts are therefore an upper bound only + // while such stale entries exist - they never include a hit this viewer + // was shown. + const { dropped, kept } = scoped.skipAccess + ? { dropped: [], kept: result.edges } + : await verifyContentSearchHits(this.c, result.edges); + if (dropped.length > 0) { + await this.refreshAccessScopes(dropped).catch(() => 0); + } + const pageInfo = + dropped.length > 0 + ? { + ...result.pageInfo, + count: kept.length, + totalCount: Math.max( + kept.length, + result.pageInfo.totalCount - dropped.length, + ), + } + : result.pageInfo; + const [withAuthors, withImages] = await Promise.all([ - this.hydrateAuthors(result.edges), - this.hydrateImages(result.edges), + this.hydrateAuthors(kept), + this.hydrateImages(kept), ]); return { - ...result, edges: withAuthors.map((edge, index) => ({ ...edge, image: withImages[index]?.image ?? null, })), + pageInfo, }; } + /** The suggestions the viewer may read now; stale ones are re-stamped. */ + private async verified( + items: T[], + ): Promise { + const { dropped, kept } = await verifyContentSearchHits(this.c, items); + if (dropped.length > 0) { + const contentTypes = this.c.get("core")?.contentTypes ?? []; + await this.refreshAccessScopes( + dropped.map(item => ({ + itemId: searchItemIdFromKey(contentTypes, item.itemType, item.itemId), + itemType: item.itemType, + })), + ).catch(() => 0); + } + + return kept; + } + async suggest({ limit, term, @@ -994,12 +1164,12 @@ export class SearchModel { limit: number; term: string; }): Promise { - return await this.withSuggestionImages( - await canonicalSearchSuggestions(this.c, { - limit, - params: this.withFallbacks(params), - term, - }), - ); + const items = await canonicalSearchSuggestions(this.c, { + limit, + params: this.withFallbacks(await this.withAccess(params)), + term, + }); + + return await this.withSuggestionImages(await this.verified(items)); } } diff --git a/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts index fe35b94a50..cf84823c7f 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/timeline.route.ts @@ -34,6 +34,9 @@ export const timelineUserAdminRoute = buildRoute({ const result = await searchTimeline(c, { authorId: Number(c.req.valid("param").id), includePrivate: true, + // A staff route behind `users:can_view`: the AdminCP sees every item, + // whatever the scopes it is filed in allow on the public site. + skipAccess: true, query: c.req.valid("query"), }); diff --git a/packages/vitnode/src/content/access/const.ts b/packages/vitnode/src/content/access/const.ts new file mode 100644 index 0000000000..c230133d80 --- /dev/null +++ b/packages/vitnode/src/content/access/const.ts @@ -0,0 +1,42 @@ +/** + * What an access policy decides, in prerequisite order: each action needs the + * one before it (`read` needs `view`, `comment` and `downloadAttachments` need + * `read`). + */ +export const CONTENT_ACCESS_ACTIONS = [ + "view", + "read", + "comment", + "downloadAttachments", +] as const; + +export const CONTENT_ACCESS_MODES = ["everyone", "members", "roles"] as const; + +/** The action each action needs first, or `null` for `view`. */ +export const CONTENT_ACCESS_PREREQUISITES = { + view: null, + read: "view", + comment: "read", + downloadAttachments: "read", +} as const; + +/** The most roles one audience may list. */ +export const CONTENT_ACCESS_MAX_ROLES = 100; + +/** The most scopes one effective-access check takes. */ +export const CONTENT_ACCESS_MAX_EFFECTIVE_SCOPES = 50; + +/** The JSON error codes the access admin routes answer with. */ +export const CONTENT_ACCESS_ERROR_CODES = [ + "invalidAccess", + "notFound", + "unknownRole", + "unsupportedStorage", +] as const; + +/** + * The header a public detail route sets when a record exists in a scope the + * viewer may see but not read. The body is the limited `{ accessRequired }` + * shape, never the record. + */ +export const CONTENT_ACCESS_REQUIRED_CODE = "CONTENT_ACCESS_REQUIRED"; diff --git a/packages/vitnode/src/content/access/policy.ts b/packages/vitnode/src/content/access/policy.ts new file mode 100644 index 0000000000..a1deb6c775 --- /dev/null +++ b/packages/vitnode/src/content/access/policy.ts @@ -0,0 +1,456 @@ +import { z } from "zod"; + +import { + CONTENT_ACCESS_ACTIONS, + CONTENT_ACCESS_MAX_ROLES, + CONTENT_ACCESS_MODES, + CONTENT_ACCESS_PREREQUISITES, +} from "./const"; + +export type ContentAccessAction = (typeof CONTENT_ACCESS_ACTIONS)[number]; + +export type ContentAccessMode = (typeof CONTENT_ACCESS_MODES)[number]; + +/** + * Who one access rule lets in: everyone (guests too), signed-in members, or + * members holding at least one of `roleIds` as a primary or secondary role. + * A `roles` audience with no role left in it lets nobody in. + */ +export type ContentAccessAudience = + | { mode: "everyone" } + | { mode: "members" } + | { mode: "roles"; roleIds: number[] }; + +/** One scope's own rules. A missing action inherits. */ +export type ContentAccessOverrides = Partial< + Record +>; + +/** A rule for every action, after inheritance. */ +export type ContentAccessPolicy = Record< + ContentAccessAction, + ContentAccessAudience +>; + +/** + * What a definition may default to. Role ids belong to one installation, so a + * code default is `everyone` or `members`; roles are picked in the AdminCP. + */ +export type ContentAccessDefaultAudience = + | { mode: "everyone" } + | { mode: "members" }; + +export type ContentAccessDefaultsInput = Partial< + Record +>; + +/** The `access` option of a container collection or a scope content type. */ +export interface ContentAccessScopeInput { + /** + * What every scope starts with before an administrator overrides it. + * Omitted actions are open to everyone, so turning access on changes + * nothing until somebody restricts a scope. + */ + defaults?: ContentAccessDefaultsInput; +} + +export interface ResolvedContentAccessScope { + defaults: ContentAccessDefaultsInput; + enabled: boolean; +} + +/** Who is asking, resolved on the server from the session and the database. */ +export type ContentAccessActor = + | { kind: "guest" } + | { + kind: "member"; + /** Primary and secondary roles, read live for this request. */ + roleIds: readonly number[]; + /** Holds a root role: passes every access rule. */ + root: boolean; + userId: number; + }; + +/** Where an inherited rule came from. */ +export type ContentAccessRuleSource = + | { kind: "default" } + | { kind: "fallback" } + | { kind: "scope"; scopeId: string }; + +/** One scope of a chain: the scope, then its ancestors, nearest first. */ +export interface ContentAccessScopeLayer { + id: string; + overrides: ContentAccessOverrides; +} + +/** One condition an actor must meet for an action. */ +export interface ContentAccessRequirement { + /** The rule's own action, e.g. `view` when an ancestor hides a scope. */ + action: ContentAccessAction; + audience: ContentAccessAudience; + /** The scope family the rule belongs to. */ + family: string; + /** The scope whose effective rule this is. */ + scopeId: string; + /** Where that scope's rule was inherited from. */ + source: ContentAccessRuleSource; + /** `ancestor` when a parent of the scope sets it. */ + via: "ancestor" | "self"; +} + +export type ContentAccessDecision = + | { allowed: false; requirement: ContentAccessRequirement } + | { allowed: true; reason: "granted" | "root" }; + +export const DISABLED_CONTENT_ACCESS_SCOPE: ResolvedContentAccessScope = { + defaults: {}, + enabled: false, +}; + +const EVERYONE: ContentAccessAudience = { mode: "everyone" }; + +/** What a stored rule that no longer parses becomes: nobody. */ +export const CONTENT_ACCESS_DENY_ALL: ContentAccessAudience = { + mode: "roles", + roleIds: [], +}; + +const zodRoleIds = z + .array(z.number().int().positive()) + .min(1) + .max(CONTENT_ACCESS_MAX_ROLES) + .transform(ids => [...new Set(ids)].sort((a, b) => a - b)); + +/** An audience as an administrator saves it: a `roles` audience names a role. */ +export const zodContentAccessAudience = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("everyone") }), + z.strictObject({ mode: z.literal("members") }), + z.strictObject({ mode: z.literal("roles"), roleIds: zodRoleIds }), +]); + +/** What a scope saves: one audience per action it overrides. */ +export const zodContentAccessOverrides = z.strictObject( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + zodContentAccessAudience.optional(), + ]), + ) as Record< + ContentAccessAction, + z.ZodOptional + >, +); + +/** An audience as a response carries it, deny-all included. */ +export const zodContentAccessAudienceRead = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("everyone") }), + z.object({ mode: z.literal("members") }), + z.object({ + mode: z.literal("roles"), + roleIds: z.array(z.number().int()), + }), +]); + +const zodDefaultAudience = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("everyone") }), + z.strictObject({ mode: z.literal("members") }), +]); + +const zodScopeInput = z.strictObject({ + defaults: z + .strictObject( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + zodDefaultAudience.optional(), + ]), + ) as Record>, + ) + .optional(), +}); + +const compactOverrides = (value: T): T => + Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as T; + +/** + * Validates the `access` option of a definition. `true` turns access on with + * every action open to everyone; `false` or nothing leaves it off. + * + * @throws {Error} The message the caller wraps in its own definition error. + */ +export const resolveContentAccessScopeInput = ( + input: boolean | ContentAccessScopeInput | undefined, +): ResolvedContentAccessScope => { + if (input === undefined || input === false) { + return DISABLED_CONTENT_ACCESS_SCOPE; + } + if (input === true) return { defaults: {}, enabled: true }; + + const parsed = zodScopeInput.safeParse(input); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const path = issue?.path.join(".") ?? ""; + throw new Error( + issue?.code === "unrecognized_keys" + ? `access${path ? `.${path}` : ""}.${issue.keys[0]} is not an option. access accepts \`defaults\` with ${CONTENT_ACCESS_ACTIONS.join(", ")}, each { mode: "everyone" } or { mode: "members" }.` + : `access${path ? `.${path}` : ""} is not valid: ${issue?.message ?? "unknown value"}. A code default is { mode: "everyone" } or { mode: "members" } - roles are chosen in the AdminCP.`, + ); + } + + return { + defaults: compactOverrides(parsed.data.defaults ?? {}), + enabled: true, + }; +}; + +/** + * Reads stored overrides defensively. A rule that no longer parses is not + * dropped - that would widen it to whatever the parent allows - but becomes + * {@link CONTENT_ACCESS_DENY_ALL}, so a corrupt row fails closed. + */ +export const readContentAccessOverrides = ( + value: unknown, +): ContentAccessOverrides => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + + const overrides: ContentAccessOverrides = {}; + for (const action of CONTENT_ACCESS_ACTIONS) { + const entry = (value as Record)[action]; + if (entry === undefined || entry === null) continue; + + const parsed = zodContentAccessAudienceRead.safeParse(entry); + overrides[action] = parsed.success + ? parsed.data.mode === "roles" + ? { mode: "roles", roleIds: [...parsed.data.roleIds] } + : { mode: parsed.data.mode } + : CONTENT_ACCESS_DENY_ALL; + } + + return overrides; +}; + +/** Whether `actor` belongs to `audience`. Root is decided by the caller. */ +export const contentAccessAudienceMatches = ( + audience: ContentAccessAudience, + actor: ContentAccessActor, +): boolean => { + switch (audience.mode) { + case "everyone": + return true; + case "members": + return actor.kind === "member"; + case "roles": + return ( + actor.kind === "member" && + actor.roleIds.some(roleId => audience.roleIds.includes(roleId)) + ); + } +}; + +export const isContentAccessAudienceOpen = ( + audience: ContentAccessAudience, +): boolean => audience.mode === "everyone"; + +/** + * The rules one scope applies, after inheritance: for each action the nearest + * scope in `chain` (the scope itself, then its ancestors) that sets it, then + * the definition's default, then everyone. + */ +export const resolveContentAccessRules = ({ + chain, + defaults, +}: { + chain: readonly ContentAccessScopeLayer[]; + defaults: ContentAccessDefaultsInput; +}): { + policy: ContentAccessPolicy; + sources: Record; +} => { + const policy = {} as ContentAccessPolicy; + const sources = {} as Record; + + for (const action of CONTENT_ACCESS_ACTIONS) { + const layer = chain.find( + candidate => candidate.overrides[action] !== undefined, + ); + const own = layer?.overrides[action]; + if (layer && own) { + policy[action] = own; + sources[action] = { kind: "scope", scopeId: layer.id }; + continue; + } + + const fallback = defaults[action]; + policy[action] = fallback ?? EVERYONE; + sources[action] = fallback ? { kind: "default" } : { kind: "fallback" }; + } + + return { policy, sources }; +}; + +/** The actions `action` needs, itself last: `comment` is view, read, comment. */ +export const contentAccessActionChain = ( + action: ContentAccessAction, +): ContentAccessAction[] => { + const actions: ContentAccessAction[] = []; + let next: ContentAccessAction | null = action; + while (next) { + actions.unshift(next); + next = CONTENT_ACCESS_PREREQUISITES[next]; + } + + return actions; +}; + +/** + * Everything an actor must satisfy to perform `action` in the scope at the + * head of `chain`, as one flat list: + * + * - its own effective rule for the action and for every prerequisite; + * - the `view` rule of every ancestor - a scope inside a hidden one is hidden; + * - for `read` and what needs it, the `read` rule of every ancestor - a child + * can be stricter than its parent, never more open. + * + * Rules open to everyone are left out, so an empty list means "anyone may". + */ +export const contentAccessRequirements = ({ + action, + chain, + defaults, + family, +}: { + action: ContentAccessAction; + chain: readonly ContentAccessScopeLayer[]; + defaults: ContentAccessDefaultsInput; + family: string; +}): ContentAccessRequirement[] => { + const [self] = chain; + if (!self) return []; + + const needed = contentAccessActionChain(action); + const requirements: ContentAccessRequirement[] = []; + const add = ( + layerIndex: number, + ruleAction: ContentAccessAction, + via: ContentAccessRequirement["via"], + ) => { + const layer = chain[layerIndex]; + const { policy, sources } = resolveContentAccessRules({ + chain: chain.slice(layerIndex), + defaults, + }); + const audience = policy[ruleAction]; + if (isContentAccessAudienceOpen(audience)) return; + + requirements.push({ + action: ruleAction, + audience, + family, + scopeId: layer.id, + source: sources[ruleAction], + via, + }); + }; + + // Ancestors first, from the root down, so the first unmet requirement names + // the outermost scope that keeps the actor out. + for (let index = chain.length - 1; index >= 1; index--) { + add(index, "view", "ancestor"); + if (needed.includes("read")) add(index, "read", "ancestor"); + } + for (const ruleAction of needed) add(0, ruleAction, "self"); + + return requirements; +}; + +/** Whether `actor` meets every requirement. A root role meets all of them. */ +export const decideContentAccess = ( + actor: ContentAccessActor, + requirements: readonly ContentAccessRequirement[], +): ContentAccessDecision => { + if (actor.kind === "member" && actor.root) { + return { allowed: true, reason: "root" }; + } + + const unmet = requirements.find( + requirement => !contentAccessAudienceMatches(requirement.audience, actor), + ); + + return unmet + ? { allowed: false, requirement: unmet } + : { allowed: true, reason: "granted" }; +}; + +/** The four yes/no answers a frontend reads for one scope or record. */ +export interface ContentAccessFlags { + canComment: boolean; + canDownloadAttachments: boolean; + canRead: boolean; + canView: boolean; +} + +export const contentAccessFlagKey = { + comment: "canComment", + downloadAttachments: "canDownloadAttachments", + read: "canRead", + view: "canView", +} as const satisfies Record; + +export const CONTENT_ACCESS_OPEN_FLAGS: ContentAccessFlags = { + canComment: true, + canDownloadAttachments: true, + canRead: true, + canView: true, +}; + +export const zodContentAccessFlags = z.object({ + canComment: z.boolean(), + canDownloadAttachments: z.boolean(), + canRead: z.boolean(), + canView: z.boolean(), +}); + +/** Every action's requirements for one chain, keyed by action. */ +export const contentAccessRequirementsByAction = (args: { + chain: readonly ContentAccessScopeLayer[]; + defaults: ContentAccessDefaultsInput; + family: string; +}): Record => + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + contentAccessRequirements({ ...args, action }), + ]), + ) as Record; + +export const contentAccessFlagsOf = ( + actor: ContentAccessActor, + requirements: Record, +): ContentAccessFlags => + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + contentAccessFlagKey[action], + decideContentAccess(actor, requirements[action]).allowed, + ]), + ) as unknown as ContentAccessFlags; + +/** Two audiences that let in exactly the same members. */ +export const sameContentAccessAudience = ( + a: ContentAccessAudience, + b: ContentAccessAudience, +): boolean => { + if (a.mode !== b.mode) return false; + if (a.mode !== "roles" || b.mode !== "roles") return true; + + const left = [...new Set(a.roleIds)].sort((x, y) => x - y); + const right = [...new Set(b.roleIds)].sort((x, y) => x - y); + + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +}; diff --git a/packages/vitnode/src/content/access/schemas.ts b/packages/vitnode/src/content/access/schemas.ts new file mode 100644 index 0000000000..acb3feb5aa --- /dev/null +++ b/packages/vitnode/src/content/access/schemas.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import type { ContentAccessAction } from "./policy"; + +import { + CONTENT_ACCESS_ACTIONS, + CONTENT_ACCESS_ERROR_CODES, +} from "./const"; +import { zodContentAccessAudienceRead } from "./policy"; + +const zodRuleSource = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("default") }), + z.object({ kind: z.literal("fallback") }), + z.object({ kind: z.literal("scope"), scopeId: z.string() }), +]); + +export const zodContentAccessRequirement = z.object({ + action: z.enum(CONTENT_ACCESS_ACTIONS), + audience: zodContentAccessAudienceRead, + family: z.string(), + scopeId: z.string(), + source: zodRuleSource, + via: z.enum(["ancestor", "self"]), +}); + +const zodActionReport = z.object({ + effective: zodContentAccessAudienceRead, + inherited: zodContentAccessAudienceRead, + inheritedSource: zodRuleSource, + prerequisite: z.enum(CONTENT_ACCESS_ACTIONS).nullable(), + requirements: z.array(zodContentAccessRequirement), + source: zodRuleSource, +}); + +/** What the AdminCP access editor shows for one scope. */ +export const zodContentAccessScopeReport = z.object({ + actions: z.object( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [action, zodActionReport]), + ) as Record, + ), + /** Scopes below this one, whose access changes with it. */ + descendants: z.number().int(), + family: z.string(), + overrides: z.record(z.string(), zodContentAccessAudienceRead), + scopeId: z.string(), + /** Actions nobody but a root role can perform here. */ + warnings: z.array( + z.object({ + action: z.enum(CONTENT_ACCESS_ACTIONS), + kind: z.enum(["nobody"]), + scopeId: z.string(), + }), + ), +}); + +export type ContentAccessScopeReport = z.infer< + typeof zodContentAccessScopeReport +>; + + +export const zodContentAccessError = z.object({ + code: z.enum(CONTENT_ACCESS_ERROR_CODES), + message: z.string(), +}); + +export const zodContentAccessAttachmentSupport = z.object({ + /** Whether the storage adapter can keep files private. */ + privateStorage: z.boolean(), +}); + +export const zodContentAccessAdminRole = z.object({ + color: z.string().nullable(), + id: z.number(), + name: z.array(z.object({ languageCode: z.string(), name: z.string() })), +}); + +export const zodContentAccessAdminResponse = z.object({ + attachments: zodContentAccessAttachmentSupport, + report: zodContentAccessScopeReport, + /** Every role the report names that still exists, for labels. */ + roles: z.array(zodContentAccessAdminRole), +}); + + +export type ContentAccessAdminRole = z.infer; + +export type ContentAccessAdminResponse = z.infer< + typeof zodContentAccessAdminResponse +>; + +/** + * What a record filed under several scopes would require, as the editor of + * an `access.from` content type asks: every scope's requirements together. + */ +export const zodContentAccessEffectiveResponse = z.object({ + actions: z.object( + Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + z.object({ requirements: z.array(zodContentAccessRequirement) }), + ]), + ) as Record< + ContentAccessAction, + z.ZodObject<{ + requirements: z.ZodArray; + }> + >, + ), + /** Scope ids that do not exist; a record filed there is readable by nobody. */ + missing: z.array(z.string()), + roles: z.array(zodContentAccessAdminRole), +}); + +export type ContentAccessEffectiveResponse = z.infer< + typeof zodContentAccessEffectiveResponse +>; diff --git a/packages/vitnode/src/content/access/server/actor.ts b/packages/vitnode/src/content/access/server/actor.ts new file mode 100644 index 0000000000..dbb06c9662 --- /dev/null +++ b/packages/vitnode/src/content/access/server/actor.ts @@ -0,0 +1,107 @@ +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentAccessActor } from "../policy"; + +import { core_roles } from "../../../database/roles"; +import { + core_users, + core_users_secondary_roles, +} from "../../../database/users"; + +const GUEST: ContentAccessActor = { kind: "guest" }; + +const actors = new WeakMap>(); + +/** + * Members' current roles and root status, read from the database - never from + * the session cache or anything a client sent - so a role removed a moment ago + * stops counting on the next request. A user id that no longer exists is a + * guest. + */ +export const loadContentAccessActors = async ( + c: Context, + userIds: readonly number[], +): Promise> => { + const ids = [...new Set(userIds)].filter(id => Number.isSafeInteger(id)); + const actorsById = new Map(); + if (ids.length === 0) return actorsById; + + const db = c.get("db"); + const rows = await db + .select({ + id: core_users.id, + roleId: core_users.roleId, + secondaryRoleId: core_users_secondary_roles.roleId, + }) + .from(core_users) + .leftJoin( + core_users_secondary_roles, + eq(core_users_secondary_roles.userId, core_users.id), + ) + .where(inArray(core_users.id, ids)); + + const roles = new Map>(); + for (const row of rows) { + const set = roles.get(row.id) ?? new Set([row.roleId]); + if (typeof row.secondaryRoleId === "number") set.add(row.secondaryRoleId); + roles.set(row.id, set); + } + + const allRoleIds = [...new Set([...roles.values()].flatMap(set => [...set]))]; + const rootRoles = new Set( + allRoleIds.length === 0 + ? [] + : ( + await db + .select({ id: core_roles.id }) + .from(core_roles) + .where( + and(inArray(core_roles.id, allRoleIds), eq(core_roles.root, true)), + ) + ).map(row => row.id), + ); + + for (const [userId, set] of roles) { + const roleIds = [...set].sort((a, b) => a - b); + actorsById.set(userId, { + kind: "member", + roleIds, + root: roleIds.some(roleId => rootRoles.has(roleId)), + userId, + }); + } + + return actorsById; +}; + +/** + * Who the current request is for, as access rules see them: a guest, or a + * member with their primary and secondary roles and whether one of them is a + * root role. Read once per request. Server-only. + */ +export const resolveContentAccessActor = async ( + c: Context, +): Promise => { + const remembered = actors.get(c); + if (remembered) return await remembered; + + const user = c.get("user") as null | undefined | { id: number }; + const load = async (): Promise => { + if (!user) return GUEST; + + return (await loadContentAccessActors(c, [user.id])).get(user.id) ?? GUEST; + }; + const promise = load(); + actors.set(c, promise); + + return await promise; +}; + +/** Forgets the memoised actor, after a write changed the user's roles. */ +export const forgetContentAccessActor = (c: Context): void => { + actors.delete(c); +}; + +export const CONTENT_ACCESS_GUEST: ContentAccessActor = GUEST; diff --git a/packages/vitnode/src/content/access/server/admin-routes.ts b/packages/vitnode/src/content/access/server/admin-routes.ts new file mode 100644 index 0000000000..8f54791ffd --- /dev/null +++ b/packages/vitnode/src/content/access/server/admin-routes.ts @@ -0,0 +1,418 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; +import { inArray } from "drizzle-orm"; +import { HTTPException } from "hono/http-exception"; + +import type { RouteValidationHook } from "../../../api/lib/route"; +import type { ContentAccessOverrides } from "../policy"; +import type { AnyContentModel } from "../../server/model"; +import type { + ContentAccessAdminRole, + ContentAccessEffectiveResponse, + ContentAccessScopeReport, +} from "../schemas"; +import type { ContentAccessFamily } from "./families"; + +import { resolveRoleNames } from "../../../api/lib/resolve-role-names"; +import { buildRoute } from "../../../api/lib/route"; +import { core_roles } from "../../../database/roles"; +import { CONTENT_PERMISSIONS } from "../../const"; +import { + CONTENT_ACCESS_ACTIONS, + type CONTENT_ACCESS_ERROR_CODES, + CONTENT_ACCESS_MAX_EFFECTIVE_SCOPES, +} from "../const"; +import { + sameContentAccessAudience, + zodContentAccessOverrides, +} from "../policy"; +import { contentAccessAttachmentSupport } from "./attachments"; +import { findContentAccessFamily, loadContentAccessFamilyState } from "./families"; +import { expireContentAccessFamily, forgetContentAccessState } from "./invalidate"; +import { + zodContentAccessAdminResponse as zodAccessResponse, + zodContentAccessEffectiveResponse, + zodContentAccessError, +} from "../schemas"; +import { contentAccessRequirementsForScopes } from "./decide"; +import { contentAccessSourceOf } from "./source"; +import { contentAccessScopeReport } from "./report"; +import { ContentAccessRoleError, replaceContentAccessRules } from "./rules"; + +/** The roles a report names, with their translated names. Missing ones are left out. */ +const rolesOf = async ( + c: Context, + report: { + actions: ContentAccessEffectiveResponse["actions"]; + overrides?: ContentAccessScopeReport["overrides"]; + }, +): Promise => { + const ids = [ + ...new Set( + CONTENT_ACCESS_ACTIONS.flatMap(action => + report.actions[action].requirements.flatMap(requirement => + requirement.audience.mode === "roles" + ? requirement.audience.roleIds + : [], + ), + ).concat( + Object.values(report.overrides ?? {}).flatMap(audience => + audience.mode === "roles" ? audience.roleIds : [], + ), + ), + ), + ]; + if (ids.length === 0) return []; + + const [rows, names] = await Promise.all([ + c + .get("db") + .select({ color: core_roles.color, id: core_roles.id }) + .from(core_roles) + .where(inArray(core_roles.id, ids)), + resolveRoleNames(c, ids), + ]); + + return rows.map(row => ({ ...row, name: names.get(row.id) ?? [] })); +}; + +const refuse = ( + status: 400 | 404 | 409, + code: (typeof CONTENT_ACCESS_ERROR_CODES)[number], + message: string, +): never => { + throw new HTTPException(status, { + res: Response.json({ code, message }, { status }), + }); +}; + +const validationHook: RouteValidationHook = (result, c) => { + if (result.success) return undefined; + + return c.json( + { + code: "invalidAccess", + message: result.error.issues + .map(issue => `${issue.path.join(".")}: ${issue.message}`) + .join("; "), + }, + 400, + ); +}; + +const errorResponse = (description: string) => ({ + content: { "application/json": { schema: zodContentAccessError } }, + description, +}); + +const idParams = z.object({ + id: z.string().regex(/^[A-Za-z0-9-]{1,64}$/), +}); + +const familyOf = (c: Context, familyId: string): ContentAccessFamily => + findContentAccessFamily(c, familyId) ?? + refuse(404, "notFound", "This collection has no access rules."); + +const reportOf = async ( + c: Context, + family: ContentAccessFamily, + scopeId: string, + preview?: ContentAccessOverrides, +) => { + const report = + (await contentAccessScopeReport(c, family, scopeId, preview)) ?? + refuse(404, "notFound", "The scope does not exist."); + + return { + attachments: contentAccessAttachmentSupport(c), + report, + roles: await rolesOf(c, report), + }; +}; + +/** + * The AdminCP access routes of one scope family, mounted under the family's + * admin module (`/{id}/access`). Read needs `can_view`, writes `can_edit` of + * the module. Plugins do not call this directly. + */ +export const buildContentAccessAdminRoutes =

({ + familyId, + permissionModule, + pluginId, +}: { + familyId: string; + permissionModule: string; + pluginId: P; +}) => { + const module = permissionModule; + + const read = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/{id}/access", + description: `Read one scope's access rules in ${familyId}: its own rules, what it inherits and every requirement its ancestors add (Admin only)`, + request: { params: idParams }, + responses: { + 200: { + content: { "application/json": { schema: zodAccessResponse } }, + description: "The scope's access report", + }, + 403: { description: "Access Denied" }, + 404: errorResponse("No such scope"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + + return c.json(await reportOf(c, familyOf(c, familyId), id), 200, { + "Cache-Control": "private, no-store", + }); + }, + }); + + const preview = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "post", + path: "/{id}/access/preview", + description: `Preview what one scope's access would be with other rules, without saving them, in ${familyId} (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { + "application/json": { + schema: z.object({ overrides: zodContentAccessOverrides }), + }, + }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodAccessResponse } }, + description: "The scope's access report with the previewed rules", + }, + 400: errorResponse("An invalid rule"), + 403: { description: "Access Denied" }, + 404: errorResponse("No such scope"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { overrides } = c.req.valid("json"); + + return c.json( + await reportOf(c, familyOf(c, familyId), id, overrides), + 200, + { "Cache-Control": "private, no-store" }, + ); + }, + }); + + const update = buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.edit }, + route: { + method: "put", + path: "/{id}/access", + description: `Replace one scope's access rules in ${familyId}. An action left out inherits from the parent and the definition (Admin only)`, + request: { + params: idParams, + body: { + required: true, + content: { + "application/json": { + schema: z.object({ overrides: zodContentAccessOverrides }), + }, + }, + }, + }, + responses: { + 200: { + content: { "application/json": { schema: zodAccessResponse } }, + description: "Rules saved", + }, + 400: errorResponse( + "An invalid rule, an unknown role, or protected downloads without private storage", + ), + 403: { description: "Access Denied" }, + 404: errorResponse("No such scope"), + }, + }, + handler: async c => { + const { id } = c.req.valid("param"); + const { overrides } = c.req.valid("json"); + const family = familyOf(c, familyId); + const state = await loadContentAccessFamilyState(c, family); + if (!state.parents.has(id)) { + return refuse(404, "notFound", "The scope does not exist."); + } + + // A download rule that keeps anybody out is a promise about bytes, which + // only private storage can keep: refuse it rather than claim it. + const download = overrides.downloadAttachments; + if ( + download && + download.mode !== "everyone" && + !contentAccessAttachmentSupport(c).privateStorage + ) { + return refuse( + 400, + "unsupportedStorage", + "The storage adapter cannot keep files private, so attachment downloads cannot be restricted.", + ); + } + + const before = state.rules.get(id) ?? {}; + try { + await replaceContentAccessRules(c.get("db"), { + family: family.id, + overrides, + scopeId: id, + }); + } catch (error) { + if (error instanceof ContentAccessRoleError) { + return refuse( + 400, + "unknownRole", + `Role ${error.roleIds.join(", ")} does not exist or cannot be chosen.`, + ); + } + throw error; + } + + const changedActions = CONTENT_ACCESS_ACTIONS.filter(action => { + const old = before[action]; + const next = overrides[action]; + if (!old || !next) return old !== next; + + return !sameContentAccessAudience(old, next); + }); + + forgetContentAccessState(c); + if (changedActions.length > 0) { + await expireContentAccessFamily(c, family); + await c.get("events").emit("content.access.updated", { + changedActions, + family: family.id, + pluginId: family.pluginId, + scopeId: id, + userId: c.get("admin")?.user.id ?? c.get("user")?.id ?? null, + }); + } + + return c.json(await reportOf(c, family, id), 200, { + "Cache-Control": "private, no-store", + }); + }, + }); + + return [read, preview, update]; +}; + +/** + * `POST /access/effective` on the admin module of a content type filed under + * scopes (`access.from`): what a record in the given scopes would require, + * every scope together - the article editor shows it before saving. Needs + * `can_view` of the content type. Plugins do not call this directly. + */ +export const buildContentAccessEffectiveRoute =

({ + contentTypeId, + permissionModule, + pluginId, +}: { + contentTypeId: string; + permissionModule: string; + pluginId: P; +}) => + buildRoute({ + pluginId, + hook: validationHook, + adminStaffPermission: { + module: permissionModule, + permission: CONTENT_PERMISSIONS.view, + }, + route: { + method: "post", + path: "/access/effective", + description: `What a ${contentTypeId} record filed in these scopes would require of a reader, every scope together (Admin only)`, + request: { + body: { + required: true, + content: { + "application/json": { + schema: z.object({ + scopeIds: z + .array(z.union([z.string().max(64), z.number().int()])) + .max(CONTENT_ACCESS_MAX_EFFECTIVE_SCOPES), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { + "application/json": { schema: zodContentAccessEffectiveResponse }, + }, + description: "Requirements per action", + }, + 403: { description: "Access Denied" }, + 404: errorResponse("The content type is not filed under scopes"), + }, + }, + handler: async c => { + const model = ( + (c.get("core")?.contentModels ?? []) as { + model: AnyContentModel; + }[] + ).find(entry => entry.model.definition.id === contentTypeId)?.model; + const source = model ? contentAccessSourceOf(c, model) : null; + if (source?.kind !== "scopes") { + return refuse(404, "notFound", "This content type has no scopes."); + } + + const ids = [ + ...new Set(c.req.valid("json").scopeIds.map(id => String(id))), + ]; + const state = await loadContentAccessFamilyState(c, source.family); + const scopes = ids.map(scopeId => ({ + family: source.family.id, + scopeId, + })); + const actions = Object.fromEntries( + await Promise.all( + CONTENT_ACCESS_ACTIONS.map( + async action => + [ + action, + { + requirements: await contentAccessRequirementsForScopes( + c, + scopes, + action, + ), + }, + ] as const, + ), + ), + ) as ContentAccessEffectiveResponse["actions"]; + + return c.json( + { + actions, + missing: ids.filter(id => !state.parents.has(id)), + roles: await rolesOf(c, { actions }), + }, + 200, + { "Cache-Control": "private, no-store" }, + ); + }, + }); diff --git a/packages/vitnode/src/content/access/server/attachments.ts b/packages/vitnode/src/content/access/server/attachments.ts new file mode 100644 index 0000000000..99526034eb --- /dev/null +++ b/packages/vitnode/src/content/access/server/attachments.ts @@ -0,0 +1,16 @@ +import type { Context } from "hono"; + +/** + * Whether the configured storage adapter can keep files private. Without it a + * file is reachable by anyone holding its URL, so attachment downloads cannot + * be restricted. Server-only. + */ +export const contentAccessAttachmentSupport = ( + c: Context, +): { privateStorage: boolean } => { + const storage = c.get("storage") as + | undefined + | { supportsPrivateFiles?: () => boolean }; + + return { privateStorage: storage?.supportsPrivateFiles?.() === true }; +}; diff --git a/packages/vitnode/src/content/access/server/decide.ts b/packages/vitnode/src/content/access/server/decide.ts new file mode 100644 index 0000000000..3dbf961bb2 --- /dev/null +++ b/packages/vitnode/src/content/access/server/decide.ts @@ -0,0 +1,246 @@ +import type { SQL } from "drizzle-orm"; +import type { Context } from "hono"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { + ContentAccessAction, + ContentAccessActor, + ContentAccessDecision, + ContentAccessFlags, + ContentAccessRequirement, +} from "../policy"; +import type { ContentAccessAllowedScopes } from "./families"; +import type { ContentAccessScopeRef } from "./source"; + +import { contentIdKey } from "../../ids"; +import { CONTENT_ACCESS_ACTIONS } from "../const"; +import { + CONTENT_ACCESS_DENY_ALL, + contentAccessFlagKey, + decideContentAccess, +} from "../policy"; +import { resolveContentAccessActor } from "./actor"; +import { + contentAccessAllowedScopes, + contentAccessScopeRequirements, + findContentAccessFamily, + loadContentAccessFamilyState, +} from "./families"; +import { + contentAccessCondition, + contentAccessScopesForValues, + contentAccessSourceOf, + contentRecordAccessScopes, + isContentAccessOpen, +} from "./source"; + +/** + * Everything `scopes` ask of an actor for `action`, together. A record in two + * scopes needs both: this is an intersection, even though one scope's `roles` + * audience lets in any of its roles. + */ +export const contentAccessRequirementsForScopes = async ( + c: Context, + scopes: readonly ContentAccessScopeRef[], + action: ContentAccessAction, +): Promise => { + const requirements: ContentAccessRequirement[] = []; + for (const scope of scopes) { + const family = findContentAccessFamily(c, scope.family); + if (!family) { + // A scope of a family that is gone, or a parent that no longer exists. + requirements.push({ + action, + audience: CONTENT_ACCESS_DENY_ALL, + family: scope.family, + scopeId: scope.scopeId, + source: { kind: "fallback" }, + via: "self", + }); + continue; + } + + const state = await loadContentAccessFamilyState(c, family); + requirements.push( + ...contentAccessScopeRequirements(state, scope.scopeId, action), + ); + } + + return requirements; +}; + +/** One decision for `action` over every scope in `scopes`. */ +export const decideContentAccessForScopes = async ( + c: Context, + { + action, + actor, + scopes, + }: { + action: ContentAccessAction; + actor?: ContentAccessActor; + scopes: readonly ContentAccessScopeRef[]; + }, +): Promise => + decideContentAccess( + actor ?? (await resolveContentAccessActor(c)), + await contentAccessRequirementsForScopes(c, scopes, action), + ); + +/** The four flags for one set of scopes. */ +export const contentAccessFlagsForScopes = async ( + c: Context, + { + actor, + scopes, + }: { actor?: ContentAccessActor; scopes: readonly ContentAccessScopeRef[] }, +): Promise> }> => { + const who = actor ?? (await resolveContentAccessActor(c)); + const flags: Record = {}; + const denied: Partial> = + {}; + for (const action of CONTENT_ACCESS_ACTIONS) { + const decision = decideContentAccess( + who, + await contentAccessRequirementsForScopes(c, scopes, action), + ); + flags[contentAccessFlagKey[action]] = decision.allowed; + if (!decision.allowed) denied[action] = decision.requirement; + } + + return { + ...(flags as unknown as ContentAccessFlags), + denied, + }; +}; + +/** + * Each record's flags, from the scopes it belongs to right now. A record + * missing from the map does not exist. Server-only. + */ +export const contentRecordAccessFlags = async ( + c: Context, + model: AnyContentModel, + ids: readonly ContentId[], + actor?: ContentAccessActor, +): Promise> => { + const scopes = await contentRecordAccessScopes(c, model, ids); + const who = actor ?? (await resolveContentAccessActor(c)); + const result = new Map(); + for (const [key, refs] of scopes) { + const { denied: _denied, ...flags } = await contentAccessFlagsForScopes(c, { + actor: who, + scopes: refs, + }); + result.set(key, flags); + } + + return result; +}; + +/** Whether `actor` may perform `action` on one record. `false` when it does not exist. */ +export const canAccessContentRecord = async ( + c: Context, + { + action, + actor, + id, + model, + }: { + action: ContentAccessAction; + actor?: ContentAccessActor; + id: ContentId; + model: AnyContentModel; + }, +): Promise => { + if (!contentAccessSourceOf(c, model)) return true; + const scopes = (await contentRecordAccessScopes(c, model, [id])).get( + contentIdKey(id), + ); + if (!scopes) return false; + + return (await decideContentAccessForScopes(c, { action, actor, scopes })) + .allowed; +}; + +/** + * The server-side access API for one request: who is asking, what they may + * do with a record or a scope, and the SQL that limits a custom query to the + * records they may read. Every decision reads the current roles, rules and + * scope tree; nothing a client sent is trusted. Server-only. + * + * Root roles pass every rule. Staff permissions grant nothing here: a + * moderator reads a restricted scope on the public site only when an access + * rule lets one of their roles in. + * + * @example + * ```ts + * import { createContentAccess } from "@vitnode/core/content/server"; + * + * const access = createContentAccess(c); + * const where = await access.condition(topicContent, "read"); + * const rows = await c.get("db").select().from(topics).where(where); + * ``` + */ +export const createContentAccess = (c: Context) => ({ + /** The current viewer, resolved once per request. */ + actor: async (): Promise => + await resolveContentAccessActor(c), + /** The scopes of one family that allow `action`. */ + allowedScopes: async ( + familyId: string, + action: ContentAccessAction, + ): Promise => { + const family = findContentAccessFamily(c, familyId); + if (!family) return { all: true, ids: [] }; + + return await contentAccessAllowedScopes(c, { + action, + actor: await resolveContentAccessActor(c), + family, + }); + }, + /** Whether the viewer may perform `action` on one record. */ + canRecord: async ( + model: AnyContentModel, + id: ContentId, + action: ContentAccessAction, + ): Promise => + await canAccessContentRecord(c, { action, id, model }), + /** Whether the viewer may perform `action` in one scope of a family. */ + canScope: async ( + familyId: string, + scopeId: number | string, + action: ContentAccessAction, + ): Promise => + ( + await decideContentAccessForScopes(c, { + action, + scopes: [{ family: familyId, scopeId: String(scopeId) }], + }) + ).allowed, + /** A `WHERE` condition limiting `model`'s rows to what the viewer may act on, or `undefined`. */ + condition: async ( + model: AnyContentModel, + action: ContentAccessAction, + ): Promise => + await contentAccessCondition(c, model, action), + /** Flags per record id key. */ + flags: async ( + model: AnyContentModel, + ids: readonly ContentId[], + ): Promise> => + await contentRecordAccessFlags(c, model, ids), + /** Whether no rule can change what anybody reads of `model`: safe to share a cache. */ + isOpen: async (model: AnyContentModel): Promise => + await isContentAccessOpen(c, model), + /** The scopes a record would belong to with these values. */ + scopesFor: async ( + model: AnyContentModel, + values: Record, + ): Promise => + await contentAccessScopesForValues(c, model, values), +}); + +export type ContentAccess = ReturnType; diff --git a/packages/vitnode/src/content/access/server/families.ts b/packages/vitnode/src/content/access/server/families.ts new file mode 100644 index 0000000000..b785e991ea --- /dev/null +++ b/packages/vitnode/src/content/access/server/families.ts @@ -0,0 +1,296 @@ +import type { Context } from "hono"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; + +import { eq } from "drizzle-orm"; + +import type { AnyContentContainerDefinition } from "../../containers/types"; +import type { AnyContentModel } from "../../server/model"; +import type { + ContentAccessAction, + ContentAccessActor, + ContentAccessDefaultsInput, + ContentAccessRequirement, + ContentAccessScopeLayer, +} from "../policy"; +import type { ContentAccessRuleMap } from "./rules"; + +import { core_content_containers } from "../../../database/content-containers"; +import { CONTENT_ACCESS_ACTIONS } from "../const"; +import { + CONTENT_ACCESS_DENY_ALL, + contentAccessRequirements, + decideContentAccess, + isContentAccessAudienceOpen, +} from "../policy"; +import { loadContentAccessRules } from "./rules"; + +/** + * A set of access scopes that share one rule store: a container collection + * (`kind: "container"`, nested) or a content type declared with + * `access: { scope }` (`kind: "contentType"`, flat). + */ +export interface ContentAccessFamily { + container?: AnyContentContainerDefinition; + defaults: ContentAccessDefaultsInput; + id: string; + kind: "container" | "contentType"; + model?: AnyContentModel; + pluginId: string; +} + +/** Every access-enabled scope family of the running app. */ +export const listContentAccessFamilies = (c: Context): ContentAccessFamily[] => { + const core = c.get("core") as + | undefined + | { + contentContainers?: { + definition: AnyContentContainerDefinition; + pluginId: string; + }[]; + contentModels?: { model: AnyContentModel; pluginId: string }[]; + }; + + return [ + ...(core?.contentContainers ?? []).flatMap(({ definition, pluginId }) => + definition.access?.enabled + ? [ + { + container: definition, + defaults: definition.access.defaults, + id: definition.id, + kind: "container" as const, + pluginId, + }, + ] + : [], + ), + ...(core?.contentModels ?? []).flatMap(({ model, pluginId }) => + model.definition.access?.scope.enabled + ? [ + { + defaults: model.definition.access.scope.defaults, + id: model.definition.id, + kind: "contentType" as const, + model, + pluginId, + }, + ] + : [], + ), + ]; +}; + +export const findContentAccessFamily = ( + c: Context, + id: string, +): ContentAccessFamily | undefined => + listContentAccessFamilies(c).find(family => family.id === id); + +/** One family's structure and rules, as one request sees them. */ +export interface ContentAccessFamilyState { + family: ContentAccessFamily; + /** Scope id -> parent scope id (`null` at the root). */ + parents: Map; + rules: ContentAccessRuleMap; +} + +const states = new WeakMap< + Context, + Map> +>(); + +const loadScopes = async ( + c: Context, + family: ContentAccessFamily, +): Promise> => { + // Read live, not from the cached container tree: a move has to change who + // may read a subtree on the very next request. + if (family.kind === "container") { + const rows = await c + .get("db") + .select({ + id: core_content_containers.id, + parentId: core_content_containers.parentId, + }) + .from(core_content_containers) + .where(eq(core_content_containers.definitionId, family.id)); + + return new Map( + rows.map(row => [ + String(row.id), + row.parentId === null ? null : String(row.parentId), + ]), + ); + } + + const model = family.model; + if (!model) return new Map(); + const columns = model.columns as Record; + const table: PgTable = model.table; + const rows = await c.get("db").select({ id: columns.id }).from(table); + + return new Map(rows.map(row => [String(row.id), null])); +}; + +export const loadContentAccessFamilyState = async ( + c: Context, + family: ContentAccessFamily, +): Promise => { + let memo = states.get(c); + if (!memo) { + memo = new Map(); + states.set(c, memo); + } + + const remembered = memo.get(family.id); + if (remembered) return await remembered; + + const promise = (async () => { + const [parents, rules] = await Promise.all([ + loadScopes(c, family), + loadContentAccessRules(c, family.id), + ]); + + return { family, parents, rules }; + })(); + memo.set(family.id, promise); + + return await promise; +}; + +/** Drops every state this request read, after it changed rules or scopes. */ +export const forgetContentAccessFamilyStates = (c: Context): void => { + states.delete(c); +}; + +/** The scope then its ancestors, nearest first, or `null` for an unknown scope. */ +export const contentAccessChainOf = ( + state: ContentAccessFamilyState, + scopeId: string, +): ContentAccessScopeLayer[] | null => { + if (!state.parents.has(scopeId)) return null; + + const chain: ContentAccessScopeLayer[] = []; + const seen = new Set(); + let next: null | string = scopeId; + while (next !== null && !seen.has(next) && state.parents.has(next)) { + seen.add(next); + chain.push({ id: next, overrides: state.rules.get(next) ?? {} }); + next = state.parents.get(next) ?? null; + } + + return chain; +}; + +/** + * What one scope asks of an actor for `action`. A scope that does not exist + * (deleted, or an id nobody resolved) asks for something nobody has, so it + * never reads as open. + */ +export const contentAccessScopeRequirements = ( + state: ContentAccessFamilyState, + scopeId: string, + action: ContentAccessAction, +): ContentAccessRequirement[] => { + const chain = contentAccessChainOf(state, scopeId); + if (!chain) { + return [ + { + action, + audience: CONTENT_ACCESS_DENY_ALL, + family: state.family.id, + scopeId, + source: { kind: "fallback" }, + via: "self", + }, + ]; + } + + return contentAccessRequirements({ + action, + chain, + defaults: state.family.defaults, + family: state.family.id, + }); +}; + +/** Which scopes of a family allow `action` for `actor`. */ +export interface ContentAccessAllowedScopes { + /** Every existing scope allows it. */ + all: boolean; + ids: string[]; +} + +const allowedMemos = new WeakMap< + Context, + Map +>(); + +const actorKey = (actor: ContentAccessActor): string => + actor.kind === "guest" + ? "guest" + : `${actor.userId}:${actor.root ? "root" : actor.roleIds.join(",")}`; + +export const contentAccessAllowedScopes = async ( + c: Context, + { + action, + actor, + family, + }: { + action: ContentAccessAction; + actor: ContentAccessActor; + family: ContentAccessFamily; + }, +): Promise => { + let memo = allowedMemos.get(c); + if (!memo) { + memo = new Map(); + allowedMemos.set(c, memo); + } + const key = `${family.id}|${action}|${actorKey(actor)}`; + const remembered = memo.get(key); + if (remembered) return remembered; + + const state = await loadContentAccessFamilyState(c, family); + const ids = [...state.parents.keys()].filter( + id => + decideContentAccess( + actor, + contentAccessScopeRequirements(state, id, action), + ).allowed, + ); + const result = { all: ids.length === state.parents.size, ids }; + memo.set(key, result); + + return result; +}; + +/** + * Whether no rule of the family keeps anybody out of anything: every default + * and every stored rule is open to everyone. Only then is a response built + * from the family the same for every viewer, and safe to cache for everybody. + */ +export const isContentAccessFamilyOpen = async ( + c: Context, + family: ContentAccessFamily, +): Promise => { + const defaultsOpen = CONTENT_ACCESS_ACTIONS.every(action => { + const audience = family.defaults[action]; + + return !audience || isContentAccessAudienceOpen(audience); + }); + if (!defaultsOpen) return false; + + const state = await loadContentAccessFamilyState(c, family); + + return [...state.rules.values()].every(overrides => + Object.values(overrides).every( + audience => !audience || isContentAccessAudienceOpen(audience), + ), + ); +}; + +export const forgetContentAccessAllowed = (c: Context): void => { + allowedMemos.delete(c); +}; diff --git a/packages/vitnode/src/content/access/server/http.ts b/packages/vitnode/src/content/access/server/http.ts new file mode 100644 index 0000000000..c791b30be6 --- /dev/null +++ b/packages/vitnode/src/content/access/server/http.ts @@ -0,0 +1,80 @@ +import type { Context } from "hono"; + +import { z } from "@hono/zod-openapi"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessActor } from "../policy"; + +import { contentAccessTag } from "../../cache"; +import { contentIdKey } from "../../ids"; +import { CONTENT_ACCESS_REQUIRED_CODE } from "../const"; +import { contentRecordAccessFlags } from "./decide"; +import { contentAccessFamilyOf, isContentAccessOpen } from "./source"; + +/** The headers of a response that depends on who is asking. */ +export const CONTENT_ACCESS_PRIVATE_HEADERS = { + "Cache-Control": "private, no-store", +} as const; + +/** + * A role, synthetic and internal, that passes every access rule. Only for + * existence checks whose answer never reaches a client as content. + */ +export const CONTENT_ACCESS_SYSTEM_ACTOR: ContentAccessActor = { + kind: "member", + roleIds: [], + root: true, + userId: 0, +}; + +/** The body of a 403 for a scope the viewer may see but not read. */ +export const zodContentAccessRequired = z.object({ + accessRequired: z.literal(true), + code: z.literal(CONTENT_ACCESS_REQUIRED_CODE), +}); + +export const contentAccessRequiredResponse = (c: Context) => + c.json( + { accessRequired: true as const, code: CONTENT_ACCESS_REQUIRED_CODE }, + 403, + CONTENT_ACCESS_PRIVATE_HEADERS, + ); + +/** + * Whether a public response for `model` may be shared by every viewer: no + * access rule can change it. Otherwise it must be `private, no-store` and stay + * out of every shared cache (the API's public-read cache and the page cache). + */ +export const isContentAccessShareable = async ( + c: Context, + model: AnyContentModel, +): Promise => await isContentAccessOpen(c, model); + +/** The extra page-cache tag of a shareable response from an access family. */ +export const contentAccessPageCacheTags = ( + c: Context, + model: AnyContentModel, +): string[] => { + const family = contentAccessFamilyOf(c, model); + + return family ? [contentAccessTag(family.id)] : []; +}; + +/** + * For a record the viewer cannot read: `"accessRequired"` when it exists and + * the viewer may see every scope it is in (so a page can say "sign in" or + * "you need access"), `"notFound"` otherwise - a hidden scope never confirms + * that anything is filed in it. + */ +export const contentAccessMissReason = async ( + c: Context, + model: AnyContentModel, + id: ContentId, +): Promise<"accessRequired" | "notFound"> => { + const flags = (await contentRecordAccessFlags(c, model, [id])).get( + contentIdKey(id), + ); + + return flags?.canView && !flags.canRead ? "accessRequired" : "notFound"; +}; diff --git a/packages/vitnode/src/content/access/server/invalidate.ts b/packages/vitnode/src/content/access/server/invalidate.ts new file mode 100644 index 0000000000..851d882072 --- /dev/null +++ b/packages/vitnode/src/content/access/server/invalidate.ts @@ -0,0 +1,79 @@ +import type { Context } from "hono"; + +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessFamily } from "./families"; + +import { invalidatePageCacheTags } from "../../../page-cache/store.server"; +import { + contentAccessTag, + contentContainerTag, + contentDeliverySitemapTag, + contentPublicListTag, +} from "../../cache"; +import { + bumpContentContainerGeneration, + bumpContentPublicGeneration, +} from "../../server/public-cache"; +import { forgetContentAccessActor } from "./actor"; +import { + forgetContentAccessAllowed, + forgetContentAccessFamilyStates, +} from "./families"; +import { forgetContentAccessRules } from "./rules"; +import { contentAccessFamilyOf } from "./source"; + +/** Content models whose records live in `family`, directly or through a parent. */ +export const contentModelsInAccessFamily = ( + c: Context, + familyId: string, +): AnyContentModel[] => + ((c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[]) + .map(entry => entry.model) + .filter(model => contentAccessFamilyOf(c, model)?.id === familyId); + +/** Drops this request's memoised access state after it changed some of it. */ +export const forgetContentAccessState = (c: Context): void => { + forgetContentAccessActor(c); + forgetContentAccessRules(c); + forgetContentAccessFamilyStates(c); + forgetContentAccessAllowed(c); +}; + +/** + * Expires everything a family's rules or tree decide, after the change + * committed: shared page-cache entries (hard, so a now-private page is never + * served again), the cached public reads of every content type filed in it, + * its container tree, and its sitemaps. Search needs nothing: results are + * checked against the current rules on every query. + */ +export const expireContentAccessFamily = async ( + c: Context, + family: Pick, +): Promise => { + forgetContentAccessState(c); + const models = contentModelsInAccessFamily(c, family.id); + + await Promise.all([ + invalidatePageCacheTags( + [ + contentAccessTag(family.id), + ...(family.kind === "container" + ? [contentContainerTag(family.id)] + : [contentPublicListTag(family.id)]), + ...models.flatMap(model => [ + contentPublicListTag(model.definition.id), + contentDeliverySitemapTag(model.definition.id), + ]), + ], + { mode: "hard" }, + ).catch(() => undefined), + family.kind === "container" + ? bumpContentContainerGeneration(c, family.id) + : bumpContentPublicGeneration(c, family.id).catch(() => undefined), + ...models.map(async model => { + await bumpContentPublicGeneration(c, model.definition.id).catch( + () => undefined, + ); + }), + ]); +}; diff --git a/packages/vitnode/src/content/access/server/report.ts b/packages/vitnode/src/content/access/server/report.ts new file mode 100644 index 0000000000..d8fd5f49b3 --- /dev/null +++ b/packages/vitnode/src/content/access/server/report.ts @@ -0,0 +1,112 @@ +import type { Context } from "hono"; + +import type { + ContentAccessAction, + ContentAccessAudience, + ContentAccessOverrides, + ContentAccessRequirement, + ContentAccessRuleSource, +} from "../policy"; +import type { ContentAccessFamily } from "./families"; + +import { CONTENT_ACCESS_ACTIONS, CONTENT_ACCESS_PREREQUISITES } from "../const"; +import type { ContentAccessScopeReport } from "../schemas"; + +import { resolveContentAccessRules } from "../policy"; +import { + contentAccessChainOf, + contentAccessScopeRequirements, + loadContentAccessFamilyState, +} from "./families"; + +interface ActionReport { + effective: ContentAccessAudience; + inherited: ContentAccessAudience; + inheritedSource: ContentAccessRuleSource; + prerequisite: ContentAccessAction | null; + requirements: ContentAccessRequirement[]; + source: ContentAccessRuleSource; +} + +/** + * One scope's own rules, what it inherits, and every requirement that applies + * once its ancestors are counted - for the AdminCP, which explains *why* a + * scope is restricted rather than only listing roles. `null` for a scope that + * does not exist. Server-only. + */ +export type { ContentAccessScopeReport } from "../schemas"; + +export const contentAccessScopeReport = async ( + c: Context, + family: ContentAccessFamily, + scopeId: string, + /** Rules to preview instead of the stored ones, before saving them. */ + preview?: ContentAccessOverrides, +): Promise => { + const loaded = await loadContentAccessFamilyState(c, family); + const state = preview + ? { + ...loaded, + rules: new Map(loaded.rules).set(scopeId, preview), + } + : loaded; + const chain = contentAccessChainOf(state, scopeId); + if (!chain) return null; + + const own = resolveContentAccessRules({ chain, defaults: family.defaults }); + const inherited = resolveContentAccessRules({ + chain: chain.slice(1), + defaults: family.defaults, + }); + + const actions = Object.fromEntries( + CONTENT_ACCESS_ACTIONS.map(action => [ + action, + { + effective: own.policy[action], + inherited: inherited.policy[action], + inheritedSource: inherited.sources[action], + prerequisite: CONTENT_ACCESS_PREREQUISITES[action], + requirements: contentAccessScopeRequirements(state, scopeId, action), + source: own.sources[action], + } satisfies ActionReport, + ]), + ) as Record; + + const children = new Map(); + for (const [id, parent] of state.parents) { + if (parent === null) continue; + children.set(parent, [...(children.get(parent) ?? []), id]); + } + let descendants = 0; + const stack = [...(children.get(scopeId) ?? [])]; + const seen = new Set(); + while (stack.length > 0) { + const next = stack.pop(); + if (next === undefined || seen.has(next)) continue; + seen.add(next); + descendants++; + stack.push(...(children.get(next) ?? [])); + } + + const warnings = CONTENT_ACCESS_ACTIONS.flatMap(action => + actions[action].requirements.flatMap(requirement => + requirement.audience.mode === "roles" && + requirement.audience.roleIds.length === 0 + ? [{ action, kind: "nobody" as const, scopeId: requirement.scopeId }] + : [], + ), + ); + + return { + actions, + descendants, + family: family.id, + overrides: (state.rules.get(scopeId) ?? {}) as Record< + string, + ContentAccessAudience + >, + scopeId, + warnings, + }; +}; diff --git a/packages/vitnode/src/content/access/server/rules.ts b/packages/vitnode/src/content/access/server/rules.ts new file mode 100644 index 0000000000..2f5991d8cc --- /dev/null +++ b/packages/vitnode/src/content/access/server/rules.ts @@ -0,0 +1,231 @@ +import type { Context } from "hono"; + +import { and, eq, inArray } from "drizzle-orm"; + +import type { ContentDatabase } from "../../server/service"; +import type { + ContentAccessAction, + ContentAccessAudience, + ContentAccessOverrides, +} from "../policy"; + +import { + core_content_access_rule_roles, + core_content_access_rules, +} from "../../../database/content-access"; +import { core_roles } from "../../../database/roles"; +import { CONTENT_ACCESS_ACTIONS } from "../const"; +import { readContentAccessOverrides } from "../policy"; + +export type ContentAccessRuleMap = Map; + +const memos = new WeakMap>>(); + +const readRules = async ( + db: ContentDatabase, + family: string, + scopeIds?: readonly string[], +): Promise => { + if (scopeIds?.length === 0) return new Map(); + + const rules = await db + .select({ + action: core_content_access_rules.action, + id: core_content_access_rules.id, + mode: core_content_access_rules.mode, + scopeId: core_content_access_rules.scopeId, + }) + .from(core_content_access_rules) + .where( + scopeIds + ? and( + eq(core_content_access_rules.family, family), + inArray(core_content_access_rules.scopeId, [...scopeIds]), + ) + : eq(core_content_access_rules.family, family), + ); + if (rules.length === 0) return new Map(); + + const roleRows = await db + .select({ + roleId: core_content_access_rule_roles.roleId, + ruleId: core_content_access_rule_roles.ruleId, + }) + .from(core_content_access_rule_roles) + .where( + inArray( + core_content_access_rule_roles.ruleId, + rules.map(rule => rule.id), + ), + ); + const rolesOf = new Map(); + for (const row of roleRows) { + rolesOf.set(row.ruleId, [...(rolesOf.get(row.ruleId) ?? []), row.roleId]); + } + + const raw = new Map>(); + for (const rule of rules) { + const entry = raw.get(rule.scopeId) ?? {}; + entry[rule.action] = + rule.mode === "roles" + ? { mode: "roles", roleIds: rolesOf.get(rule.id) ?? [] } + : { mode: rule.mode }; + raw.set(rule.scopeId, entry); + } + + // Through the defensive reader: an unknown mode becomes deny-all rather + // than nothing, and an unknown action is ignored. + return new Map( + [...raw].map(([scopeId, value]) => [ + scopeId, + readContentAccessOverrides(value), + ]), + ); +}; + +/** + * Every stored rule of one scope family, keyed by scope id. Read live once per + * request, so a rule saved a moment ago applies on the next request. Server-only. + */ +export const loadContentAccessRules = async ( + c: Context, + family: string, +): Promise => { + let memo = memos.get(c); + if (!memo) { + memo = new Map(); + memos.set(c, memo); + } + + const remembered = memo.get(family); + if (remembered) return await remembered; + + const promise = readRules(c.get("db"), family); + memo.set(family, promise); + + return await promise; +}; + +export const forgetContentAccessRules = (c: Context): void => { + memos.delete(c); +}; + +export class ContentAccessRoleError extends Error { + constructor(roleIds: number[]) { + super(`Unknown or unusable role ids: ${roleIds.join(", ")}.`); + this.name = "ContentAccessRoleError"; + this.roleIds = roleIds; + } + + readonly roleIds: number[]; +} + +/** + * The role ids among `ids` that cannot be used in an audience: missing ones and + * the guest role (an audience of roles is for signed-in members). + */ +export const unusableContentAccessRoleIds = async ( + db: ContentDatabase, + ids: readonly number[], +): Promise => { + if (ids.length === 0) return []; + const rows = await db + .select({ guest: core_roles.guest, id: core_roles.id }) + .from(core_roles) + .where(inArray(core_roles.id, [...ids])); + const usable = new Set(rows.filter(row => !row.guest).map(row => row.id)); + + return ids.filter(id => !usable.has(id)); +}; + +/** + * Replaces one scope's rules with `overrides` in one transaction. An action + * left out inherits. Validated by the caller with `zodContentAccessOverrides`. + * + * @throws {ContentAccessRoleError} When an audience names a role that does not + * exist or is the guest role. + */ +export const replaceContentAccessRules = async ( + db: ContentDatabase, + { + family, + overrides, + scopeId, + }: { family: string; overrides: ContentAccessOverrides; scopeId: string }, +): Promise => { + const roleIds = [ + ...new Set( + Object.values(overrides).flatMap(audience => + audience?.mode === "roles" ? audience.roleIds : [], + ), + ), + ]; + + await db.transaction(async tx => { + const unusable = await unusableContentAccessRoleIds(tx, roleIds); + if (unusable.length > 0) throw new ContentAccessRoleError(unusable); + + await tx + .delete(core_content_access_rules) + .where( + and( + eq(core_content_access_rules.family, family), + eq(core_content_access_rules.scopeId, scopeId), + ), + ); + + const entries = CONTENT_ACCESS_ACTIONS.flatMap(action => { + const audience = overrides[action]; + + return audience ? [[action, audience] as const] : []; + }); + if (entries.length === 0) return; + + const inserted = await tx + .insert(core_content_access_rules) + .values( + entries.map(([action, audience]) => ({ + action, + family, + mode: audience.mode, + scopeId, + })), + ) + .returning({ + action: core_content_access_rules.action, + id: core_content_access_rules.id, + }); + + const audienceOf = new Map(entries); + const links = inserted.flatMap(rule => { + const audience = audienceOf.get(rule.action as ContentAccessAction); + + return audience?.mode === "roles" + ? audience.roleIds.map(roleId => ({ roleId, ruleId: rule.id })) + : []; + }); + if (links.length > 0) { + await tx.insert(core_content_access_rule_roles).values(links); + } + }); +}; + +/** Removes every rule of the given scopes, when the scopes themselves go. */ +export const deleteContentAccessRules = async ( + db: ContentDatabase, + family: string, + scopeIds: readonly string[], +): Promise => { + if (scopeIds.length === 0) return; + await db + .delete(core_content_access_rules) + .where( + and( + eq(core_content_access_rules.family, family), + inArray(core_content_access_rules.scopeId, [...scopeIds]), + ), + ); +}; + +/** Whether any rule of the family exists at all, for diagnostics. */ +export const readContentAccessRulesOf = readRules; diff --git a/packages/vitnode/src/content/access/server/search.ts b/packages/vitnode/src/content/access/server/search.ts new file mode 100644 index 0000000000..b2448045a7 --- /dev/null +++ b/packages/vitnode/src/content/access/server/search.ts @@ -0,0 +1,178 @@ +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, inArray } from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { ContentAccessActor } from "../policy"; +import type { ContentAccessScopeRef } from "./source"; + +import { contentIdKey, parseContentId } from "../../ids"; +import { resolveContentAccessActor } from "./actor"; +import { + contentAccessAllowedScopes, + listContentAccessFamilies, + loadContentAccessFamilyState, +} from "./families"; +import { + contentAccessCondition, + contentAccessSourceOf, + contentRecordAccessScopes, +} from "./source"; + +/** How a scope is written into the search index: `family:scopeId`. */ +export const contentSearchAccessToken = ({ + family, + scopeId, +}: ContentAccessScopeRef): string => `${family}:${scopeId}`; + +const modelOf = (c: Context, itemType: string): AnyContentModel | undefined => + ((c.get("core")?.contentModels ?? []) as { model: AnyContentModel }[]).find( + entry => entry.model.definition.id === itemType, + )?.model; + +/** + * Stamps each document of an access-controlled content type with the scopes + * its record is filed in right now, read from the database. Every index write + * goes through here, whatever built the document. Server-only. + */ +export const withContentSearchAccessScopes = async < + TDoc extends { accessScopes?: readonly string[]; itemId: ContentId; itemType: string }, +>( + c: Context, + docs: readonly TDoc[], +): Promise => { + const byType = new Map(); + for (const doc of docs) { + byType.set(doc.itemType, [...(byType.get(doc.itemType) ?? []), doc]); + } + + const scopesByKey = new Map(); + for (const [itemType, group] of byType) { + const model = modelOf(c, itemType); + if (!model || !contentAccessSourceOf(c, model)) continue; + + const scopes = await contentRecordAccessScopes( + c, + model, + group.map(doc => doc.itemId), + ); + for (const doc of group) { + const refs = scopes.get(contentIdKey(doc.itemId)); + // A record the database no longer has belongs to no scope anybody may + // read: the token names nothing. + scopesByKey.set( + `${itemType}|${contentIdKey(doc.itemId)}`, + refs + ? refs.map(contentSearchAccessToken) + : [`:missing:${contentIdKey(doc.itemId)}`], + ); + } + } + + return docs.map(doc => { + const tokens = scopesByKey.get(`${doc.itemType}|${contentIdKey(doc.itemId)}`); + + return tokens ? { ...doc, accessScopes: tokens } : doc; + }); +}; + +/** + * The scope tokens the viewer may read, for a search query: a document is + * kept only when every token it carries is in the list. `null` when nothing + * restricts the viewer (no access families, or a root role) - no filter at + * all. Server-only. + */ +export const contentSearchAllowedScopes = async ( + c: Context, + actor?: ContentAccessActor, +): Promise => { + const who = actor ?? (await resolveContentAccessActor(c)); + if (who.kind === "member" && who.root) return null; + + const families = listContentAccessFamilies(c); + if (families.length === 0) return null; + + const tokens: string[] = []; + let restricted = false; + for (const family of families) { + const allowed = await contentAccessAllowedScopes(c, { + action: "read", + actor: who, + family, + }); + if (!allowed.all) restricted = true; + // Every scope of a fully open family too: the filter is "all of a + // document's scopes are allowed", so an open family's scopes must be in it. + const ids = allowed.all + ? [...(await loadContentAccessFamilyState(c, family)).parents.keys()] + : allowed.ids; + for (const id of ids) tokens.push(`${family.id}:${id}`); + } + + return restricted ? tokens : null; +}; + +/** + * Which of `hits` the viewer may read, checked against the content tables now + * - not against what the index remembers. A document indexed before its + * record moved into a restricted scope, or before a role was taken away, is + * dropped here. Hits of item types without access rules pass. Server-only. + */ +export const verifyContentSearchHits = async < + THit extends { itemId: ContentId | string; itemType: string }, +>( + c: Context, + hits: readonly THit[], +): Promise<{ dropped: THit[]; kept: THit[] }> => { + const actor = await resolveContentAccessActor(c); + if (actor.kind === "member" && actor.root) return { dropped: [], kept: [...hits] }; + + const readable = new Map>(); + const types = [...new Set(hits.map(hit => hit.itemType))]; + for (const itemType of types) { + const model = modelOf(c, itemType); + if (!model || !contentAccessSourceOf(c, model)) continue; + + const condition = await contentAccessCondition(c, model, "read", actor); + const ids = hits + .filter(hit => hit.itemType === itemType) + .flatMap(hit => { + const id = parseContentId(model.definition.idStrategy, hit.itemId); + + return id === null ? [] : [id]; + }); + if (!condition) { + readable.set(itemType, new Set(ids.map(id => contentIdKey(id)))); + continue; + } + const columns = model.columns as Record; + const table: PgTable = model.table; + const rows = + ids.length === 0 + ? [] + : await c + .get("db") + .select({ id: columns.id }) + .from(table) + .where(and(inArray(columns.id, ids), condition)); + readable.set( + itemType, + new Set(rows.map(row => contentIdKey(row.id as ContentId))), + ); + } + + const kept: THit[] = []; + const dropped: THit[] = []; + for (const hit of hits) { + const allowed = readable.get(hit.itemType); + if (!allowed || allowed.has(contentIdKey(hit.itemId as ContentId))) { + kept.push(hit); + } else { + dropped.push(hit); + } + } + + return { dropped, kept }; +}; diff --git a/packages/vitnode/src/content/access/server/source.ts b/packages/vitnode/src/content/access/server/source.ts new file mode 100644 index 0000000000..610226a64a --- /dev/null +++ b/packages/vitnode/src/content/access/server/source.ts @@ -0,0 +1,488 @@ +import type { SQL } from "drizzle-orm"; +import type { PgColumn, PgTable } from "drizzle-orm/pg-core"; +import type { Context } from "hono"; + +import { and, eq, inArray, isNull, not, notInArray, or, sql } from "drizzle-orm"; + +import type { ContentId } from "../../ids"; +import type { AnyContentModel } from "../../server/model"; +import type { AnyContentTypeDefinition } from "../../types"; +import type { ContentAccessAction, ContentAccessActor } from "../policy"; +import type { ContentAccessFamily } from "./families"; + +import { contentContainerFieldOf } from "../../containers/association"; +import { contentIdKey } from "../../ids"; +import { contentSearchContextSource } from "../../search"; +import { resolveContentAccessActor } from "./actor"; +import { + contentAccessAllowedScopes, + isContentAccessFamilyOpen, + listContentAccessFamilies, +} from "./families"; + +/** + * How a content type's records map to access scopes: + * + * - `container` - its own `field.container()` of an access-enabled collection; + * - `scopes` - `access.from`, a relation to a scope content type, where a + * record must be allowed by every scope it points at; + * - `parent` - a relation to a record that has access of its own (a reply's + * thread root, a comment's article), whose scopes it shares. + */ +export type ContentAccessSource = + | { + column: PgColumn; + family: ContentAccessFamily; + field: string; + kind: "container"; + } + | { + column: PgColumn; + field: string; + kind: "parent"; + parent: AnyContentModel; + } + | { + column?: PgColumn; + family: ContentAccessFamily; + field: string; + junction?: { itemId: PgColumn; relatedItemId: PgColumn; table: PgTable }; + kind: "scopes"; + }; + +/** A scope one record belongs to. */ +export interface ContentAccessScopeRef { + family: string; + scopeId: string; +} + +const modelsOf = ( + c: Context, +): { model: AnyContentModel; pluginId: string }[] => + (c.get("core")?.contentModels ?? []) as { + model: AnyContentModel; + pluginId: string; + }[]; + +const modelById = (c: Context, id: string): AnyContentModel | undefined => + modelsOf(c).find(entry => entry.model.definition.id === id)?.model; + +/** The relation fields that make a record answer a parent, in priority order. */ +const parentFieldsOf = ( + definition: AnyContentTypeDefinition, +): { field: string; target: AnyContentTypeDefinition }[] => { + const candidates: { field: string; target: AnyContentTypeDefinition }[] = []; + const add = (field: string) => { + const descriptor = definition.fields[field]; + if ( + descriptor?.kind !== "relation" || + descriptor.multiple || + candidates.some(entry => entry.field === field) + ) { + return; + } + try { + const target = descriptor.target(); + if (target.id !== definition.id) candidates.push({ field, target }); + } catch { + // A relation whose target cannot load is no parent. + } + }; + + if (definition.thread.enabled) add(definition.thread.rootField); + for (const [name, descriptor] of Object.entries(definition.fields)) { + if ( + descriptor.kind === "relation" && + (descriptor as { container?: boolean }).container === true + ) { + add(name); + } + } + const context = contentSearchContextSource(definition); + if (context?.kind === "relation") add(context.field); + + return candidates; +}; + +// Per registry, then per model: the answer depends on which families the +// running app registered, and tests build more than one app per process. +const sources = new WeakMap< + object, + WeakMap +>(); +const NO_REGISTRY = {}; + +const sourceCacheOf = ( + c: Context, +): WeakMap => { + const core = (c.get("core") as object | undefined) ?? NO_REGISTRY; + let cache = sources.get(core); + if (!cache) { + cache = new WeakMap(); + sources.set(core, cache); + } + + return cache; +}; + +/** + * The access source of one model, or `null` when its records belong to no + * access scope. Static for the life of the process. + */ +export const contentAccessSourceOf = ( + c: Context, + model: AnyContentModel, + visiting: ReadonlySet = new Set(), +): ContentAccessSource | null => { + const cache = sourceCacheOf(c); + if (cache.has(model)) return cache.get(model) ?? null; + + const { definition } = model; + const columns = model.columns as Record; + const families = listContentAccessFamilies(c); + const resolve = (): ContentAccessSource | null => { + const from = definition.access?.from; + if (from) { + const descriptor = definition.fields[from]; + const target = + descriptor?.kind === "relation" ? descriptor.target() : undefined; + const family = families.find( + candidate => + candidate.kind === "contentType" && candidate.id === target?.id, + ); + if (!family) return null; + + const junction = ( + model.advancedTables.junctions as unknown as Record< + string, + undefined | Record + > + )[from]; + + return junction + ? { + family, + field: from, + junction: { + itemId: junction.itemId as PgColumn, + relatedItemId: junction.relatedItemId as PgColumn, + table: junction as unknown as PgTable, + }, + kind: "scopes", + } + : { column: columns[from], family, field: from, kind: "scopes" }; + } + + const container = contentContainerFieldOf(definition); + if (container) { + const family = families.find( + candidate => + candidate.kind === "container" && + candidate.id === container.field.definition.id, + ); + const column = columns[container.name]; + if (family && column) { + return { column, family, field: container.name, kind: "container" }; + } + } + + const next = new Set(visiting).add(definition.id); + for (const { field, target } of parentFieldsOf(definition)) { + if (next.has(target.id)) continue; + const parent = modelById(c, target.id); + const column = columns[field]; + if (!parent || !column) continue; + if (contentAccessSourceOf(c, parent, next)) { + return { column, field, kind: "parent", parent }; + } + } + + return null; + }; + + const source = resolve(); + // Only cache a complete answer; a call mid-cycle may be partial. + if (visiting.size === 0) cache.set(model, source); + + return source; +}; + +/** The family at the end of a source's parent chain. */ +export const contentAccessFamilyOf = ( + c: Context, + model: AnyContentModel, +): ContentAccessFamily | null => { + const source = contentAccessSourceOf(c, model); + if (!source) return null; + if (source.kind === "parent") return contentAccessFamilyOf(c, source.parent); + + return source.family; +}; + +/** + * Whether no access rule can change who reads this content type: it has no + * access source, or its family is open to everyone. Only then may a public + * response for it go into a cache shared by every viewer. + */ +export const isContentAccessOpen = async ( + c: Context, + model: AnyContentModel, +): Promise => { + const family = contentAccessFamilyOf(c, model); + + return !family || (await isContentAccessFamilyOpen(c, family)); +}; + +const NOTHING: SQL = sql`false`; + +const scopeIdValues = ( + ids: readonly string[], + numeric: boolean, +): (number | string)[] => + numeric ? ids.map(Number).filter(id => Number.isSafeInteger(id)) : [...ids]; + +const isNumericColumn = (column: PgColumn): boolean => + column.columnType === "PgInteger" || + column.columnType === "PgSerial" || + column.dataType === "number"; + +/** + * The `WHERE` condition that keeps only the records of `model` that `actor` + * may perform `action` on, or `undefined` when every record qualifies. Built + * from the scopes' current rules and tree, so it is the same check whatever + * reads the table: lists before pagination, single reads, counts and joins. + * Server-only. + */ +export const contentAccessCondition = async ( + c: Context, + model: AnyContentModel, + action: ContentAccessAction, + actor?: ContentAccessActor, +): Promise => { + const source = contentAccessSourceOf(c, model); + if (!source) return undefined; + const who = actor ?? (await resolveContentAccessActor(c)); + if (who.kind === "member" && who.root) return undefined; + + if (source.kind === "parent") { + const parentCondition = await contentAccessCondition( + c, + source.parent, + action, + who, + ); + if (!parentCondition) return undefined; + + const parentColumns = source.parent.columns as Record; + const parentTable: PgTable = source.parent.table; + + return or( + isNull(source.column), + inArray( + source.column, + c + .get("db") + .select({ id: parentColumns.id }) + .from(parentTable) + .where(parentCondition), + ), + ); + } + + const allowed = await contentAccessAllowedScopes(c, { + action, + actor: who, + family: source.family, + }); + if (allowed.all) return undefined; + + if (source.kind === "scopes" && source.junction) { + const { itemId, relatedItemId, table } = source.junction; + const columns = model.columns as Record; + const values = scopeIdValues(allowed.ids, isNumericColumn(relatedItemId)); + const blocked = + values.length === 0 + ? undefined + : notInArray(relatedItemId, values as never[]); + + // Allowed by every scope it points at: no junction row names a scope + // outside the allowed set. + return not( + sql`exists (select 1 from ${table} where ${and( + eq(itemId, columns.id), + blocked, + )})`, + ); + } + + const column = source.column; + if (!column) return NOTHING; + const values = scopeIdValues(allowed.ids, isNumericColumn(column)); + + return values.length === 0 + ? isNull(column) + : or(isNull(column), inArray(column, values as never[])); +}; + +/** + * The scopes each of `ids` currently belongs to, read from the database. A + * record missing from the result does not exist; one with an empty list has + * no scope and is open. + */ +export const contentRecordAccessScopes = async ( + c: Context, + model: AnyContentModel, + ids: readonly ContentId[], +): Promise> => { + const result = new Map(); + if (ids.length === 0) return result; + + const source = contentAccessSourceOf(c, model); + const columns = model.columns as Record; + const table: PgTable = model.table; + const db = c.get("db"); + + if (!source) { + const rows = await db + .select({ id: columns.id }) + .from(table) + .where(inArray(columns.id, [...ids])); + for (const row of rows) result.set(contentIdKey(row.id as ContentId), []); + + return result; + } + + if (source.kind === "scopes" && source.junction) { + const rows = await db + .select({ id: columns.id }) + .from(table) + .where(inArray(columns.id, [...ids])); + for (const row of rows) result.set(contentIdKey(row.id as ContentId), []); + + const links = await db + .select({ + itemId: source.junction.itemId, + relatedItemId: source.junction.relatedItemId, + }) + .from(source.junction.table) + .where(inArray(source.junction.itemId, [...ids])); + for (const link of links) { + const key = contentIdKey(link.itemId as ContentId); + result.get(key)?.push({ + family: source.family.id, + scopeId: String(link.relatedItemId), + }); + } + + return result; + } + + const column = source.column; + if (!column) return result; + const rows = await db + .select({ id: columns.id, value: column }) + .from(table) + .where(inArray(columns.id, [...ids])); + + if (source.kind === "parent") { + const parentIds = [ + ...new Map( + rows + .map(row => row.value) + .filter( + (value): value is number | string => + typeof value === "number" || typeof value === "string", + ) + .map(value => [contentIdKey(value), value]), + ).values(), + ]; + const parents = await contentRecordAccessScopes( + c, + source.parent, + parentIds, + ); + for (const row of rows) { + const value = row.value; + const key = contentIdKey(row.id as ContentId); + if (value === null || value === undefined) { + result.set(key, []); + continue; + } + const scopes = parents.get(contentIdKey(value as ContentId)); + // A parent that no longer exists resolves to nothing - never to "open". + result.set( + key, + scopes ?? [{ family: "", scopeId: `missing:${String(value)}` }], + ); + } + + return result; + } + + for (const row of rows) { + const value = row.value; + result.set( + contentIdKey(row.id as ContentId), + value === null || value === undefined + ? [] + : [{ family: source.family.id, scopeId: String(value) }], + ); + } + + return result; +}; + +/** + * The scopes a record would belong to if `values` were written: the container + * or scope ids in the payload, or the scopes of the parent it names. The + * caller passes values it already validated; nothing here trusts a client's + * claim about scopes beyond the ids, which are resolved on the server. + */ +export const contentAccessScopesForValues = async ( + c: Context, + model: AnyContentModel, + values: Record, +): Promise => { + const source = contentAccessSourceOf(c, model); + if (!source) return []; + + const value = values[source.field]; + if (value === undefined || value === null) return []; + + if (source.kind === "parent") { + if (typeof value !== "number" && typeof value !== "string") return null; + const parents = await contentRecordAccessScopes(c, source.parent, [value]); + + return parents.get(contentIdKey(value)) ?? null; + } + + const ids = Array.isArray(value) ? value : [value]; + + return ids.map(id => ({ + family: source.family.id, + scopeId: String( + typeof id === "object" && id !== null && "id" in id + ? (id as { id: unknown }).id + : id, + ), + })); +}; + +/** + * {@link contentAccessCondition} for a content type by its definition, through + * the model the app registered for it. The public read services call this on + * every read. + */ +export const contentPublicAccessCondition = async ( + c: Context, + definition: Pick, + { + action = "read", + actor, + }: { action?: ContentAccessAction; actor?: ContentAccessActor } = {}, +): Promise => { + const model = modelById(c, definition.id); + if (!model) return undefined; + + return await contentAccessCondition(c, model, action, actor); +}; diff --git a/packages/vitnode/src/content/cache.ts b/packages/vitnode/src/content/cache.ts index 707f1d8dab..c4eefbbd17 100644 --- a/packages/vitnode/src/content/cache.ts +++ b/packages/vitnode/src/content/cache.ts @@ -76,6 +76,14 @@ export const contentDeliverySitemapTag = ( locale?: string, ): string => tag(contentTypeId, "sitemap", ...localeParts(locale)); +/** + * Every public read that an access scope family decides: the family's own + * scopes and every record filed in them. Expired whenever the family's rules + * or its tree change. + */ +export const contentAccessTag = (familyId: string): string => + tag(familyId, "access"); + /** Every public read of a content container definition's containers. */ export const contentContainerTag = (definitionId: string): string => tag(definitionId, "containers"); diff --git a/packages/vitnode/src/content/const.ts b/packages/vitnode/src/content/const.ts index 64bfa4ab09..987935fd65 100644 --- a/packages/vitnode/src/content/const.ts +++ b/packages/vitnode/src/content/const.ts @@ -273,6 +273,8 @@ export const CONTENT_PUBLIC_WRITABLE_KINDS = [ * use, and `invalid_json` a body that is not JSON. */ export const CONTENT_PUBLIC_WRITE_ISSUE_CODES = { + /** The scope's access rules keep this member from reading or replying there. */ + accessDenied: "access_denied", editWindowClosed: "edit_window_closed", invalidJson: "invalid_json", moderatorsOnly: "moderators_only", diff --git a/packages/vitnode/src/content/containers/define.ts b/packages/vitnode/src/content/containers/define.ts index 5da837bf0e..b36e26543e 100644 --- a/packages/vitnode/src/content/containers/define.ts +++ b/packages/vitnode/src/content/containers/define.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { ContentAccessScopeInput } from "../access/policy"; import type { ContentFieldDescriptor, ContentFieldMap } from "../types"; import type { ContentContainerPoliciesInput } from "./policies"; import type { @@ -12,6 +13,7 @@ import type { ContentContainerSchemas, } from "./types"; +import { resolveContentAccessScopeInput } from "../access/policy"; import { CONTENT_ADMIN_PATH_SEGMENT_PATTERN, CONTENT_FIELD_NAME_PATTERN, @@ -361,6 +363,7 @@ export const defineContentContainer = < | false | undefined = undefined, >({ + access, admin, fields = {} as TFields, hierarchy, @@ -369,6 +372,20 @@ export const defineContentContainer = < policies, publicApi, }: { + /** + * Per-container access rules: who may see a container (`view`), read what is + * filed in it (`read`), reply to it (`comment`) and download its protected + * attachments (`downloadAttachments`). Containers inherit from their parent + * and never open up what a parent hides. `true` starts every action open to + * everyone; `defaults` sets `{ mode: "members" }` for some. Omit it and the + * collection has no access rules at all. + * + * Only turn it on when every custom route of the plugin that reads content + * filed here goes through the Content Engine's public services or + * `createContentAccess(c)` - a route that queries the table directly would + * ignore the rules. + */ + access?: boolean | ContentAccessScopeInput; admin?: ContentContainerAdminInput; fields?: TFields; hierarchy?: ContentContainerHierarchyInput; @@ -409,7 +426,15 @@ export const defineContentContainer = < const resolvedPublicApi = resolvePublicApi(id, fields, publicApi); + let resolvedAccess; + try { + resolvedAccess = resolveContentAccessScopeInput(access); + } catch (error) { + return fail(id, (error as Error).message); + } + return { + access: resolvedAccess, admin: resolveAdmin(id, admin), fields, hierarchy: resolveHierarchy(id, hierarchy), diff --git a/packages/vitnode/src/content/containers/server/admin-routes.ts b/packages/vitnode/src/content/containers/server/admin-routes.ts index aeb5e2a55e..da6683aa3e 100644 --- a/packages/vitnode/src/content/containers/server/admin-routes.ts +++ b/packages/vitnode/src/content/containers/server/admin-routes.ts @@ -14,6 +14,7 @@ import type { import type { ContentContainerUsageCheck } from "./service"; import { buildRoute } from "../../../api/lib/route"; +import { buildContentAccessAdminRoutes } from "../../access/server/admin-routes"; import { CONTENT_PERMISSIONS } from "../../const"; import { CONTENT_CONTAINER_ERROR_CODES } from "../const"; import { ContentContainerError } from "../errors"; @@ -257,8 +258,16 @@ export const buildContentContainerAdminRoutes = < }, }); + const access = definition.access.enabled + ? buildContentAccessAdminRoutes({ + familyId: definition.id, + permissionModule: module, + pluginId, + }) + : []; + if (!definition.policies.enabled) { - return [list, create, update, move, remove]; + return [list, create, update, move, remove, ...access]; } const policies = buildRoute({ @@ -337,5 +346,14 @@ export const buildContentContainerAdminRoutes = < }, }); - return [list, create, update, move, remove, policies, updatePolicies]; + return [ + list, + create, + update, + move, + remove, + policies, + updatePolicies, + ...access, + ]; }; diff --git a/packages/vitnode/src/content/containers/server/public-routes.ts b/packages/vitnode/src/content/containers/server/public-routes.ts index 1de5014f59..91ce602f7b 100644 --- a/packages/vitnode/src/content/containers/server/public-routes.ts +++ b/packages/vitnode/src/content/containers/server/public-routes.ts @@ -6,9 +6,21 @@ import type { ContentContainerPublicRecord, } from "../types"; +import type { ContentAccessFlags } from "../../access/policy"; + import { setPageCacheTags } from "../../../api/lib/page-cache"; import { buildRoute } from "../../../api/lib/route"; -import { contentContainerTag } from "../../cache"; +import { + CONTENT_ACCESS_OPEN_FLAGS, + zodContentAccessFlags, +} from "../../access/policy"; +import { contentAccessFlagsForScopes } from "../../access/server/decide"; +import { + findContentAccessFamily, + isContentAccessFamilyOpen, +} from "../../access/server/families"; +import { CONTENT_ACCESS_PRIVATE_HEADERS } from "../../access/server/http"; +import { contentAccessTag, contentContainerTag } from "../../cache"; import { CONTENT_CONTAINER_SLUG_MAX_LENGTH } from "../const"; import { contentContainerAncestors } from "../tree"; import { createContentContainerService } from "./service"; @@ -26,12 +38,87 @@ export const buildContentContainerPublicRoutes = < definition: TDefinition, { pluginId }: { pluginId: P }, ) => { - const record = definition.schemas.publicRecord as unknown as z.ZodType< - ContentContainerPublicRecord + // Every container says what the viewer may do in it - the frontend shows + // "sign in to read" from this, the server still decides. Without access + // rules every flag is `true`. + const record = definition.schemas.publicRecord.extend({ + access: zodContentAccessFlags, + }) as unknown as z.ZodType< + ContentContainerPublicRecord & { + access: ContentAccessFlags; + } >; const service = (c: Parameters[0]) => createContentContainerService(c, { definition, pluginId }); + type Context = Parameters[0]; + type Item = Awaited["list"]>>[number]; + + /** + * Every container the viewer may see, each with its flags; a container + * inside a hidden one is hidden too. `shareable` when no rule of the + * collection keeps anybody out, so the answer is everybody's. + */ + const visibleContainers = async ( + c: Context, + all: Item[], + ): Promise<{ + flags: Map; + items: Item[]; + shareable: boolean; + }> => { + const family = definition.access.enabled + ? findContentAccessFamily(c, definition.id) + : undefined; + if (!family) { + return { + flags: new Map(all.map(item => [item.id, CONTENT_ACCESS_OPEN_FLAGS])), + items: all, + shareable: true, + }; + } + + const flags = new Map(); + for (const item of all) { + const { denied: _denied, ...decided } = await contentAccessFlagsForScopes( + c, + { scopes: [{ family: family.id, scopeId: String(item.id) }] }, + ); + flags.set(item.id, decided); + } + + return { + flags, + items: all.filter(item => flags.get(item.id)?.canView === true), + shareable: await isContentAccessFamilyOpen(c, family), + }; + }; + + const withAccess = + ( + containers: ReturnType, + flags: Map, + ) => + (item: Item) => ({ + ...containers.toPublic(item), + access: flags.get(item.id) ?? CONTENT_ACCESS_OPEN_FLAGS, + }); + + const respond = ( + c: Context, + body: T, + shareable: boolean, + ) => { + if (!shareable) return c.json(body, 200, CONTENT_ACCESS_PRIVATE_HEADERS); + + setPageCacheTags(c, [ + contentContainerTag(definition.id), + ...(definition.access.enabled ? [contentAccessTag(definition.id)] : []), + ]); + + return c.json(body, 200); + }; + const list = buildRoute({ pluginId, route: { @@ -61,7 +148,10 @@ export const buildContentContainerPublicRoutes = < handler: async c => { const { parent } = c.req.valid("query"); const containers = service(c); - const all = await containers.list(); + const { flags, items: all, shareable } = await visibleContainers( + c, + await containers.list(), + ); let items = all; if (parent === "root") { @@ -73,9 +163,11 @@ export const buildContentContainerPublicRoutes = < items = all.filter(item => item.parentId === parent); } - setPageCacheTags(c, [contentContainerTag(definition.id)]); - - return c.json({ items: items.map(containers.toPublic) }, 200); + return respond( + c, + { items: items.map(withAccess(containers, flags)) }, + shareable, + ); }, }); @@ -109,7 +201,12 @@ export const buildContentContainerPublicRoutes = < handler: async c => { const { idOrSlug } = c.req.valid("param"); const containers = service(c); - const all = await containers.list(); + const { flags, items: all, shareable } = await visibleContainers( + c, + await containers.list(), + ); + // A container the viewer may not see is not found, like one that never + // existed - its slug and id answer the same. const container = NUMERIC_ID.test(idOrSlug) ? all.find(item => item.id === Number(idOrSlug)) : all.find(item => item.slug === idOrSlug); @@ -118,18 +215,18 @@ export const buildContentContainerPublicRoutes = < } const ancestors = contentContainerAncestors(all, container.id); + const toPublic = withAccess(containers, flags); - setPageCacheTags(c, [contentContainerTag(definition.id)]); - - return c.json( + return respond( + c, { - ancestors: ancestors.map(containers.toPublic), + ancestors: ancestors.map(toPublic), children: all .filter(item => item.parentId === container.id) - .map(containers.toPublic), - container: containers.toPublic(container), + .map(toPublic), + container: toPublic(container), }, - 200, + shareable, ); }, }); diff --git a/packages/vitnode/src/content/containers/server/service.ts b/packages/vitnode/src/content/containers/server/service.ts index 9a864b3f9f..0db51c0d9c 100644 --- a/packages/vitnode/src/content/containers/server/service.ts +++ b/packages/vitnode/src/content/containers/server/service.ts @@ -22,6 +22,8 @@ import type { } from "../types"; import { core_content_containers } from "../../../database/content-containers"; +import { expireContentAccessFamily } from "../../access/server/invalidate"; +import { deleteContentAccessRules } from "../../access/server/rules"; import { invalidatePageCacheTags } from "../../../page-cache/store.server"; import { contentContainerTag } from "../../cache"; import { contentDefinitionOf } from "../../server/model"; @@ -368,6 +370,11 @@ export const createContentContainerService = < await invalidatePageCacheTags([contentContainerTag(definitionId)], { mode, }); + // A move or a delete changes who may read the subtree, so everything the + // collection's access rules decided goes too. + if (definition.access.enabled) { + await expireContentAccessFamily(c, { id: definitionId, kind: "container" }); + } }; const emit = async < @@ -536,6 +543,7 @@ export const createContentContainerService = < eq(core_content_containers.definitionId, definitionId), ), ); + await deleteContentAccessRules(tx, definitionId, [String(id)]); await applyPlacements(tx, plan.shifted); return row; diff --git a/packages/vitnode/src/content/containers/types.ts b/packages/vitnode/src/content/containers/types.ts index fb518b2dd7..7d2a4398b0 100644 --- a/packages/vitnode/src/content/containers/types.ts +++ b/packages/vitnode/src/content/containers/types.ts @@ -14,6 +14,7 @@ import type { CONTENT_CONTAINER_ERROR_CODES, CONTENT_CONTAINER_ORDERING_MODES, } from "./const"; +import type { ResolvedContentAccessScope } from "../access/policy"; import type { ResolvedContentContainerPolicies } from "./policies"; export type ContentContainerFieldDescriptor = @@ -96,6 +97,8 @@ export interface ContentContainerDefinition< TPublicEnabled extends boolean = boolean, TPublicPath extends string = string, > { + /** Per-container access rules, or the disabled default. */ + access: ResolvedContentAccessScope; admin: { navigation: { enabled: boolean }; path: string; diff --git a/packages/vitnode/src/content/define-access.ts b/packages/vitnode/src/content/define-access.ts new file mode 100644 index 0000000000..77166fc582 --- /dev/null +++ b/packages/vitnode/src/content/define-access.ts @@ -0,0 +1,86 @@ +import type { ContentAccessScopeInput } from "./access/policy"; +import type { ContentFieldMap } from "./types"; + +import { + DISABLED_CONTENT_ACCESS_SCOPE, + resolveContentAccessScopeInput, +} from "./access/policy"; +import { ContentEngineError } from "./errors"; + +/** + * The `access` option of a content type, in one of two shapes: + * + * - `{ scope: true | { defaults } }` - the content type's records are access + * scopes themselves (a blog category): each gets its own view, read, comment + * and download rules in the AdminCP. + * - `{ from: "categoryId" }` - a relation field pointing at a scope content + * type. A record is readable only when **every** scope it points at allows + * the action. + */ +export type ContentAccessInput = + | { from: TField } + | { scope: ContentAccessScopeInput | true }; + +export interface ResolvedContentTypeAccess { + /** The relation field whose targets are this type's scopes, or `null`. */ + from: null | string; + scope: typeof DISABLED_CONTENT_ACCESS_SCOPE; +} + +export const DISABLED_CONTENT_TYPE_ACCESS: ResolvedContentTypeAccess = { + from: null, + scope: DISABLED_CONTENT_ACCESS_SCOPE, +}; + +export const resolveContentTypeAccess = ( + id: string, + fields: ContentFieldMap, + access: ContentAccessInput | false | undefined, +): ResolvedContentTypeAccess => { + if (access === undefined || access === false) { + return DISABLED_CONTENT_TYPE_ACCESS; + } + + const fail = (message: string): never => { + throw new ContentEngineError(message, { contentTypeId: id }); + }; + + if (typeof access !== "object" || access === null || Array.isArray(access)) { + return fail( + 'access is `{ scope: true }`, `{ scope: { defaults } }` or `{ from: "" }`.', + ); + } + + const keys = Object.keys(access); + if (keys.length !== 1 || (keys[0] !== "scope" && keys[0] !== "from")) { + return fail( + 'access takes exactly one of `scope` (records of this type are access scopes) or `from` (a relation to a scope content type).', + ); + } + + if ("scope" in access) { + try { + return { + from: null, + scope: resolveContentAccessScopeInput(access.scope), + }; + } catch (error) { + return fail((error as Error).message.replace(/^access/, "access.scope")); + } + } + + const name = access.from; + const field = fields[name]; + if (field?.kind !== "relation") { + return fail( + `access.from names "${String(name)}", which is not a relation field of this content type.`, + ); + } + if (field.localized === true) { + return fail( + `access.from names the localized relation "${name}". Access scopes are shared by every language - use a shared relation.`, + ); + } + + return { from: name, scope: DISABLED_CONTENT_ACCESS_SCOPE }; +}; diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts index 3364b4fdb0..8b7431501b 100644 --- a/packages/vitnode/src/content/define.ts +++ b/packages/vitnode/src/content/define.ts @@ -48,6 +48,8 @@ import type { ResolvedContentVisibilityConfig, } from "./types"; +import type { ContentAccessInput } from "./define-access"; + import { contentEntityKey } from "./admin/labels"; import { assertContentRelationTargets, @@ -58,6 +60,7 @@ import { CONTENT_IDENTIFIER_MAX_LENGTH, CONTENT_TABLE_NAME_PATTERN, } from "./const"; +import { resolveContentTypeAccess } from "./define-access"; import { resolveAdmin } from "./define-admin"; import { resolveEditorial } from "./define-editorial"; import { @@ -301,6 +304,7 @@ export const defineContentType = < TPublicPeople extends ContentPublicPersonField = never, TPublicListField extends TPublicField = never, >({ + access, admin = {}, audit, delivery, @@ -321,6 +325,15 @@ export const defineContentType = < thread, visibility, }: { + /** + * Role-based access rules. `{ scope: true }` makes every record of this type + * an access scope with its own view, read, comment and download rules (a + * blog category); `{ from: "categoryId" }` files records under the scopes a + * relation points at, and a record is readable only when every one of them + * allows it. Records that answer a parent (thread replies, comments) inherit + * their parent's access without any option. Omit it and nothing changes. + */ + access?: ContentAccessInput | false; /** * How the generated AdminCP screens present the content type: list columns, * form layout, dialog or page mode, title field, URL path and the staff @@ -762,6 +775,7 @@ export const defineContentType = < ContentPublicPeopleResolved, ContentPublicListFieldsResolved > = { + access: resolveContentTypeAccess(id, fieldMap, access), admin: resolvedAdmin, advanced: resolvedAdvanced, audit: resolveContentAudit(id, audit, fieldNames), diff --git a/packages/vitnode/src/content/errors.ts b/packages/vitnode/src/content/errors.ts index e6ae956a52..4f79c2b761 100644 --- a/packages/vitnode/src/content/errors.ts +++ b/packages/vitnode/src/content/errors.ts @@ -444,7 +444,7 @@ export class ContentPublicWriteDenied extends ContentEngineError { }: { contentTypeId: string; /** Why, when the reason alone would leave the member guessing. */ - issue?: "edit_window_closed" | "moderators_only" | null; + issue?: "access_denied" | "edit_window_closed" | "moderators_only" | null; reason: ContentPublicWriteDenial; }) { super(`Public write denied: ${reason}.`, { contentTypeId }); @@ -454,7 +454,11 @@ export class ContentPublicWriteDenied extends ContentEngineError { this.reason = reason; } - readonly issue: "edit_window_closed" | "moderators_only" | null; + readonly issue: + | "access_denied" + | "edit_window_closed" + | "moderators_only" + | null; readonly reason: ContentPublicWriteDenial; } diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index ce6c22ef24..fdd20e4e3f 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -1,3 +1,33 @@ +export { + CONTENT_ACCESS_ACTIONS, + CONTENT_ACCESS_MODES, + CONTENT_ACCESS_PREREQUISITES, + CONTENT_ACCESS_REQUIRED_CODE, +} from "./access/const"; +export { + CONTENT_ACCESS_OPEN_FLAGS, + contentAccessActionChain, + contentAccessAudienceMatches, + contentAccessRequirements, + decideContentAccess, + resolveContentAccessRules, + zodContentAccessAudience, + zodContentAccessFlags, + zodContentAccessOverrides, +} from "./access/policy"; +export type { + ContentAccessAction, + ContentAccessActor, + ContentAccessAudience, + ContentAccessDecision, + ContentAccessDefaultsInput, + ContentAccessFlags, + ContentAccessOverrides, + ContentAccessPolicy, + ContentAccessRequirement, + ContentAccessScopeInput, +} from "./access/policy"; +export type { ContentAccessInput } from "./define-access"; export { contentContainerAdminHref, contentContainerI18nKeys, @@ -74,6 +104,7 @@ export type { export { zodContentApprovalState } from "./approvals/schema"; export type { ContentApprovalState } from "./approvals/schema"; export { + contentAccessTag, contentContainerTag, contentDeliveryRedirectTag, contentDeliverySitemapTag, diff --git a/packages/vitnode/src/content/server/delivery-routes.ts b/packages/vitnode/src/content/server/delivery-routes.ts index b076ee2972..00a0feca00 100644 --- a/packages/vitnode/src/content/server/delivery-routes.ts +++ b/packages/vitnode/src/content/server/delivery-routes.ts @@ -5,10 +5,15 @@ import { HTTPException } from "hono/http-exception"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentDeliveryService } from "./delivery-service"; -import type { ContentModel } from "./model"; +import type { AnyContentModel, ContentModel } from "./model"; import { setPageCacheTags } from "../../api/lib/page-cache"; import { buildRoute } from "../../api/lib/route"; +import { + CONTENT_ACCESS_PRIVATE_HEADERS, + contentAccessPageCacheTags, + isContentAccessShareable, +} from "../access/server/http"; import { contentTypeName } from "../admin/labels"; import { CONTENT_DELIVERY_REDIRECT_STATUS, @@ -36,6 +41,7 @@ export const buildContentDeliveryRoutes = < { pluginId }: { pluginId: P }, ) => { const { definition } = model; + const accessModel = model as unknown as AnyContentModel; const name = contentTypeName(definition.id); const localized = definition.localization.enabled; @@ -171,11 +177,23 @@ export const buildContentDeliveryRoutes = < locale: resolved.locale, }); + // A resolution depends on what this viewer may read once any access + // rule applies, so it is then nobody else's. + const shareable = await isContentAccessShareable(c, accessModel); + if (!shareable) { + return c.json(resolution, 200, { + ...CONTENT_ACCESS_PRIVATE_HEADERS, + ...(resolution.type === "content" && resolution.hidden + ? { "X-Robots-Tag": "noindex, nofollow" } + : {}), + }); + } + if (resolution.type === "content" && !resolution.hidden) { - setPageCacheTags( - c, - contentRecordPageCacheTags(definition, resolution.itemId), - ); + setPageCacheTags(c, [ + ...contentRecordPageCacheTags(definition, resolution.itemId), + ...contentAccessPageCacheTags(c, accessModel), + ]); } return resolution.type === "content" && resolution.hidden @@ -219,7 +237,14 @@ export const buildContentDeliveryRoutes = < }); if (!metadata) throw notFound(name); - setPageCacheTags(c, contentRecordPageCacheTags(definition, id)); + if (!(await isContentAccessShareable(c, accessModel))) { + return c.json(metadata, 200, CONTENT_ACCESS_PRIVATE_HEADERS); + } + + setPageCacheTags(c, [ + ...contentRecordPageCacheTags(definition, id), + ...contentAccessPageCacheTags(c, accessModel), + ]); return c.json(metadata, 200); }, diff --git a/packages/vitnode/src/content/server/delivery-sitemap.ts b/packages/vitnode/src/content/server/delivery-sitemap.ts index bfa8e06912..b59ec56d06 100644 --- a/packages/vitnode/src/content/server/delivery-sitemap.ts +++ b/packages/vitnode/src/content/server/delivery-sitemap.ts @@ -16,6 +16,8 @@ import { CONTENT_SITEMAP_MAX_URLS, } from "../const"; import { contentDeliveryPublicUrl } from "../delivery"; +import { CONTENT_ACCESS_GUEST } from "../access/server/actor"; +import { contentPublicAccessCondition } from "../access/server/source"; import { ContentDeliveryNotEnabled } from "../errors"; import { parseContentId } from "../ids"; import { splitContentFieldPath } from "../paths"; @@ -95,6 +97,11 @@ export const readContentDeliverySitemapPage = async < const conditions: (SQL | undefined)[] = [ publishedCondition(base), + // A sitemap is public, so it lists only what a guest may read - whoever + // happens to be asking for it. + await contentPublicAccessCondition(c, definition, { + actor: CONTENT_ACCESS_GUEST, + }), cursor === null ? undefined : gt(columns.id, cursor), // `IS DISTINCT FROM TRUE`, which is the only spelling that agrees with the // metadata. `contentDeliveryRobots` reads `value !== true`, so a `noIndex` of diff --git a/packages/vitnode/src/content/server/index.ts b/packages/vitnode/src/content/server/index.ts index eab8f2574f..e3559b54f1 100644 --- a/packages/vitnode/src/content/server/index.ts +++ b/packages/vitnode/src/content/server/index.ts @@ -1,4 +1,48 @@ export { escapeLikePattern } from "../../lib/api/like-pattern"; +export { + CONTENT_ACCESS_GUEST, + loadContentAccessActors, + resolveContentAccessActor, +} from "../access/server/actor"; +export { buildContentAccessAdminRoutes } from "../access/server/admin-routes"; +export { + canAccessContentRecord, + contentAccessFlagsForScopes, + contentAccessRequirementsForScopes, + contentRecordAccessFlags, + createContentAccess, + decideContentAccessForScopes, +} from "../access/server/decide"; +export type { ContentAccess } from "../access/server/decide"; +export { + contentAccessAllowedScopes, + findContentAccessFamily, + isContentAccessFamilyOpen, + listContentAccessFamilies, +} from "../access/server/families"; +export type { + ContentAccessAllowedScopes, + ContentAccessFamily, +} from "../access/server/families"; +export { + contentModelsInAccessFamily, + expireContentAccessFamily, +} from "../access/server/invalidate"; +export { contentAccessScopeReport } from "../access/server/report"; +export type { ContentAccessScopeReport } from "../access/server/report"; +export { deleteContentAccessRules } from "../access/server/rules"; +export { + contentAccessCondition, + contentAccessFamilyOf, + contentAccessScopesForValues, + contentAccessSourceOf, + contentRecordAccessScopes, + isContentAccessOpen, +} from "../access/server/source"; +export type { + ContentAccessScopeRef, + ContentAccessSource, +} from "../access/server/source"; export { ContentSubmissionDecisionError, decideContentSubmission, diff --git a/packages/vitnode/src/content/server/localized-public-service.ts b/packages/vitnode/src/content/server/localized-public-service.ts index a91f6fc4b8..858e87ec52 100644 --- a/packages/vitnode/src/content/server/localized-public-service.ts +++ b/packages/vitnode/src/content/server/localized-public-service.ts @@ -23,7 +23,10 @@ import type { ContentPublicService, } from "./public-service"; +import type { ContentAccessActor } from "../access/policy"; + import { withPagination } from "../../api/lib/with-pagination"; +import { contentPublicAccessCondition } from "../access/server/source"; import { CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, CONTENT_PUBLIC_MAX_PAGE_SIZE, @@ -406,9 +409,10 @@ export const createContentLocalizedPublicService = < resolved: ResolvedLocale, condition: SQL | undefined, { + actor, hidden = false, strict = false, - }: { hidden?: boolean; strict?: boolean } = {}, + }: { actor?: ContentAccessActor; hidden?: boolean; strict?: boolean } = {}, ): Promise | null> => { // A strict read must not fall back - `findBySlug`. Dropping the fallback // language makes the join inner and the visibility test single-armed, so @@ -424,6 +428,7 @@ export const createContentLocalizedPublicService = < contentPublicReadCondition(basePublication, { hidden }), visibleIn(scope), condition, + await contentPublicAccessCondition(c, definition, { actor }), ), { limit: 1 }, ); @@ -441,6 +446,7 @@ export const createContentLocalizedPublicService = < if (!resolved) return null; return await readOne(readerFor(options), resolved, eq(columns.id, id), { + actor: options?.actor, hidden: options?.hidden, }); }, @@ -466,7 +472,7 @@ export const createContentLocalizedPublicService = < projection, resolved, slugIn(resolved.requested.id), - { hidden: options?.hidden, strict: true }, + { actor: options?.actor, hidden: options?.hidden, strict: true }, ); if (row || !options?.fallback || !resolved.fallbackTo) return row; @@ -474,11 +480,12 @@ export const createContentLocalizedPublicService = < projection, resolved, slugIn(resolved.fallbackTo.id), - { hidden: options?.hidden }, + { actor: options?.actor, hidden: options?.hidden }, ); }, findMany: async ({ + actor, filters = {}, hidden, locale, @@ -507,6 +514,7 @@ export const createContentLocalizedPublicService = < ); const where = conditions( + await contentPublicAccessCondition(c, definition, { actor }), contentPublicListCondition({ advanced, columns, definition }, hidden), // The localized filter goes through the same visibility test as the read // itself, so a filter can only ever match the translation the reader diff --git a/packages/vitnode/src/content/server/public-permissions.ts b/packages/vitnode/src/content/server/public-permissions.ts index e0e4ff1446..af202264a8 100644 --- a/packages/vitnode/src/content/server/public-permissions.ts +++ b/packages/vitnode/src/content/server/public-permissions.ts @@ -8,7 +8,12 @@ import type { ContentId } from "../ids"; import type { AnyContentTypeDefinition } from "../types"; import type { ContentModel } from "./model"; +import type { ContentAccessFlags } from "../access/policy"; + import { buildRoute } from "../../api/lib/route"; +import { CONTENT_ACCESS_OPEN_FLAGS } from "../access/policy"; +import { contentRecordAccessFlags } from "../access/server/decide"; +import { contentAccessSourceOf } from "../access/server/source"; import { contentTypeName } from "../admin/labels"; import { CONTENT_PUBLIC_MAX_PAGE_SIZE } from "../const"; import { @@ -27,7 +32,7 @@ import { import { isContentBaseRowPublic } from "./visibility"; /** What the current visitor may do with one record. */ -export interface ContentViewerPermissions { +export interface ContentViewerPermissions extends ContentAccessFlags { /** * `POST /{id}/delete` will accept this visitor: a moderator with * `can_delete`, or the author when one of their roles allows deleting own @@ -90,10 +95,29 @@ export const resolveContentViewerPermissions = async ( .from(table) .where(inArray(columns.id, ids))) as Record[]; const user = c.get("user"); + const accessModel = model as unknown as Parameters< + typeof contentRecordAccessFlags + >[1]; + // Replies are written under their parent's `comment` rule, everything else + // under the scope's `read` rule. + const writeFlag: keyof ContentAccessFlags = + contentAccessSourceOf(c, accessModel)?.kind === "parent" + ? "canComment" + : "canRead"; + const accessFlags = await contentRecordAccessFlags( + c, + accessModel, + rows.map(row => row.id as ContentId), + ); const decided = await Promise.all( rows.map(async row => { const id = row.id as ContentId; + const access = + accessFlags.get(contentIdKey(id)) ?? CONTENT_ACCESS_OPEN_FLAGS; + // A record the viewer may not read is not described at all - not even + // to its author, whose access may have been taken away since. + if (!access.canRead) return null; const isPublic = isContentBaseRowPublic(row); const ownerId = contentPublicWriteOwnerOf(definition, row); const isOwner = user !== null && ownerId === user.id; @@ -115,7 +139,7 @@ export const resolveContentViewerPermissions = async ( policy: effectiveContentPolicy(definition, container), row, }); - canEdit = decision.allowed; + canEdit = decision.allowed && access[writeFlag]; } const isPublished = isContentBaseRowPublic({ ...row, hiddenAt: null }); @@ -131,6 +155,7 @@ export const resolveContentViewerPermissions = async ( if (!isPublic && !canEdit && !canHide && !canDelete) return null; return { + ...access, canDelete, canEdit, canHide, @@ -176,7 +201,7 @@ export const contentPermissionsRoute = < route: { method: "get", path: "/permissions", - description: `What the current visitor may do with each listed ${name}: edit, delete, hide or report it`, + description: `What the current visitor may do with each listed ${name}: read, comment on, download from, edit, delete, hide or report it`, request: { query: z.object({ ids: z @@ -192,10 +217,14 @@ export const contentPermissionsRoute = < schema: z.object({ items: z.array( z.object({ + canComment: z.boolean(), canDelete: z.boolean(), + canDownloadAttachments: z.boolean(), canEdit: z.boolean(), canHide: z.boolean(), + canRead: z.boolean(), canReport: z.boolean(), + canView: z.boolean(), id: schemas.params.shape.id, }), ), diff --git a/packages/vitnode/src/content/server/public-routes.ts b/packages/vitnode/src/content/server/public-routes.ts index 127c11ec20..4bf21afe98 100644 --- a/packages/vitnode/src/content/server/public-routes.ts +++ b/packages/vitnode/src/content/server/public-routes.ts @@ -21,12 +21,25 @@ import type { ContentPreviewTokenPayload } from "./preview-token"; import type { ContentPublicService } from "./public-service"; import type { ContentThreadNode } from "./thread"; +import type { AnyContentModel } from "./model"; + import { setPageCacheTags } from "../../api/lib/page-cache"; import { buildRoute } from "../../api/lib/route"; import { zodPaginationPageInfo, zodPaginationQuery, } from "../../api/lib/with-pagination"; +import { decideContentAccessForScopes } from "../access/server/decide"; +import { + CONTENT_ACCESS_PRIVATE_HEADERS, + CONTENT_ACCESS_SYSTEM_ACTOR, + contentAccessMissReason, + contentAccessPageCacheTags, + contentAccessRequiredResponse, + isContentAccessShareable, + zodContentAccessRequired, +} from "../access/server/http"; +import { contentAccessSourceOf } from "../access/server/source"; import { contentTypeName } from "../admin/labels"; import { contentContainerTag } from "../cache"; import { @@ -150,6 +163,49 @@ export const buildContentPublicRoutes = < }); const project = createContentPublicProjector(definition); + const accessModel = model as unknown as AnyContentModel; + + /** + * A list filtered by the scope its records are filed in (a container, or an + * `access.from` scope): `"notFound"` when the viewer may not see that scope, + * `"accessRequired"` when they may see it but not read inside it. + */ + const filterScopeRefusal = async ( + c: Context, + filters: Record, + ): Promise<"accessRequired" | "notFound" | null> => { + const source = contentAccessSourceOf(c, accessModel); + if (!source || source.kind === "parent") return null; + + const requested = filters[source.field]; + const ids = ( + typeof requested === "object" && requested !== null && "in" in requested + ? (requested as { in: unknown[] }).in + : [requested] + ).filter( + (value): value is number | string => + typeof value === "number" || typeof value === "string", + ); + if (ids.length === 0) return null; + + for (const id of ids) { + const scopes = [{ family: source.family.id, scopeId: String(id) }]; + if ( + !(await decideContentAccessForScopes(c, { action: "view", scopes })) + .allowed + ) { + return "notFound"; + } + if ( + !(await decideContentAccessForScopes(c, { action: "read", scopes })) + .allowed + ) { + return "accessRequired"; + } + } + + return null; + }; /** * Which language this request is for, or `null` - always a 404. An explicit @@ -331,6 +387,13 @@ export const buildContentPublicRoutes = < description: `Up to ${CONTENT_PUBLIC_MAX_PAGE_SIZE} published ${name} records`, }, 400: { description: "Invalid query parameters" }, + 403: { + content: { + "application/json": { schema: zodContentAccessRequired }, + }, + description: + "The filter names a scope the viewer may see but not read", + }, 404: { description: "The requested locale is not served, or the container filter names a container that does not exist", @@ -370,6 +433,15 @@ export const buildContentPublicRoutes = < } } + const refusal = await filterScopeRefusal( + c, + filters as Record, + ); + if (refusal === "notFound") { + throw new HTTPException(404, { message: "Container not found." }); + } + if (refusal === "accessRequired") return contentAccessRequiredResponse(c); + // An explicit locale naming no language this install serves is the same // 404 the detail route answers, not an empty list: an empty list would say // "this language has no articles", which is a different and untrue thing. @@ -377,6 +449,7 @@ export const buildContentPublicRoutes = < if (!resolved) throw notFound(); const hidden = await contentListHiddenAccess(c, { model, pluginId }); + const shareable = await isContentAccessShareable(c, accessModel); const readPage = async () => await service(c).findMany({ filters, @@ -394,9 +467,9 @@ export const buildContentPublicRoutes = < query: { cursor, first, last, page, search }, }); - if (hidden) { + if (hidden || !shareable) { return c.json(await readPage(), 200, { - "Cache-Control": "private, no-store", + ...CONTENT_ACCESS_PRIVATE_HEADERS, ...contentLocaleHeaders(resolved), }); } @@ -418,6 +491,7 @@ export const buildContentPublicRoutes = < setPageCacheTags(c, [ ...contentListPageCacheTags(definition), + ...contentAccessPageCacheTags(c, accessModel), ...(containerFilter ? [contentContainerTag(containerFilter.field.definition.id)] : []), @@ -634,12 +708,17 @@ export const buildContentPublicRoutes = < items: page.items.map(serializeThreadNode), }; - if (hiddenAccess || readable.hidden) { - return c.json(body, 200, { "Cache-Control": "private, no-store" }); + if ( + hiddenAccess || + readable.hidden || + !(await isContentAccessShareable(c, accessModel)) + ) { + return c.json(body, 200, CONTENT_ACCESS_PRIVATE_HEADERS); } setPageCacheTags(c, [ ...contentListPageCacheTags(definition), + ...contentAccessPageCacheTags(c, accessModel), ...contentListPageCacheTags(readable.target), ]); @@ -788,45 +867,62 @@ export const buildContentPublicRoutes = < }, description: `${name} found`, }, + 403: { + content: { + "application/json": { schema: zodContentAccessRequired }, + }, + description: `${name} is in a scope the viewer may see but not read. The body says only that`, + }, 404: { description: `${name} not found` }, }, }, handler: async c => { const resolved = await localeFor(c); if (!resolved) throw notFound(); + const shareable = await isContentAccessShareable(c, accessModel); // A draft, an unpublished row, a cleared publication date, a translation // that is not published in this language and a typo are all the same 404. // A 403 would confirm the record exists, which is the one thing a draft // URL must not do - and so would a 404 that only some of them produced. const slug = c.req.param("slug"); - // Only the published read is cached - it is the same for every reader. - // The hidden fallback below depends on who is asking and never is. - const row = await rememberContentPublicRead(c, { - contentTypeId: definition.id, - key: { - locale: resolved.locale ?? null, - route: "detail", - slug, - source: resolved.source, - }, - load: async () => - await service(c).findBySlug(slug, { - fallback: true, - locale: resolved.locale, - }), - }); + const readRow = async () => + await service(c).findBySlug(slug, { + fallback: true, + locale: resolved.locale, + }); + // Only a read no access rule can change is cached - it is the same for + // every reader. The hidden fallback below depends on who is asking and + // never is. + const row = shareable + ? await rememberContentPublicRead(c, { + contentTypeId: definition.id, + key: { + locale: resolved.locale ?? null, + route: "detail", + slug, + source: resolved.source, + }, + load: readRow, + }) + : await readRow(); + if (row && !shareable) { + return c.json(row, 200, { + ...CONTENT_ACCESS_PRIVATE_HEADERS, + ...contentLocaleHeaders(resolved), + }); + } if (row) { - setPageCacheTags( - c, - contentRecordPageCacheTags( + setPageCacheTags(c, [ + ...contentRecordPageCacheTags( definition, parseContentId( definition.idStrategy, (row as Record).id, ), ), - ); + ...contentAccessPageCacheTags(c, accessModel), + ]); return c.json(row, 200, contentLocaleHeaders(resolved)); } @@ -837,7 +933,33 @@ export const buildContentPublicRoutes = < pluginId, slug, }); - if (!hidden) throw notFound(); + if (!hidden) { + if (shareable) throw notFound(); + + // Published, but outside what this viewer may read: say so only when + // they may see where it is filed, and say nothing else about it. + const existing = await service(c).findBySlug(slug, { + actor: CONTENT_ACCESS_SYSTEM_ACTOR, + fallback: true, + locale: resolved.locale, + select: ["id"], + }); + const id = existing + ? parseContentId( + definition.idStrategy, + (existing as Record).id, + ) + : null; + if ( + id !== null && + (await contentAccessMissReason(c, accessModel, id)) === + "accessRequired" + ) { + return contentAccessRequiredResponse(c); + } + + throw notFound(); + } return c.json(hidden, 200, { "Cache-Control": "private, no-store", diff --git a/packages/vitnode/src/content/server/public-service.ts b/packages/vitnode/src/content/server/public-service.ts index 24dbb89906..eb8652bd02 100644 --- a/packages/vitnode/src/content/server/public-service.ts +++ b/packages/vitnode/src/content/server/public-service.ts @@ -9,6 +9,7 @@ import type { Context } from "hono"; import { and, eq, inArray, or } from "drizzle-orm"; import type { PaginationCursorColumn } from "../../api/lib/with-pagination"; +import type { ContentAccessActor } from "../access/policy"; import type { ContentIdOf } from "../ids"; import type { AnyContentTypeDefinition, @@ -25,6 +26,7 @@ import type { import type { ContentPageInfo } from "./service"; import { withPagination } from "../../api/lib/with-pagination"; +import { contentPublicAccessCondition } from "../access/server/source"; import { CONTENT_PUBLIC_DEFAULT_PAGE_SIZE, CONTENT_PUBLIC_MAX_PAGE_SIZE, @@ -59,6 +61,12 @@ import { export { nestContentPublicRow } from "./public-row-hydration"; export interface ContentPublicReadOptions { + /** + * Whose access rules the read applies. Defaults to the request's own viewer; + * pass a guest to build something every visitor will see (a sitemap), or a + * recipient to check what a notification may say. + */ + actor?: ContentAccessActor; /** Language to read, on a localized content type. Defaults to `localization.defaultLocale`. */ locale?: string; } @@ -145,6 +153,8 @@ export interface ContentPublicService { findManyByIds?: ( ids: readonly ContentIdOf[], options?: { + /** Whose access rules apply. Defaults to the request's viewer. */ + actor?: ContentAccessActor; /** * Also read published records that are hidden. Only for ids the caller * already cleared for this reader, such as a thread's moderators. @@ -482,12 +492,17 @@ export const createContentPublicService = < const { hydrate, project, selection } = options.select ? reader(contentPublicSelectProjection(definition, options.select)) : detail; + const access = await contentPublicAccessCondition(c, definition, { + actor: options.actor, + }); const [row] = await keepVisible( await c .get("db") .select(withParent(selection)) .from(table) - .where(and(contentPublicReadCondition(published, options), condition)) + .where( + and(contentPublicReadCondition(published, options), condition, access), + ) .limit(1), ); @@ -514,20 +529,32 @@ export const createContentPublicService = < contentPublicReadCondition(published, { hidden: true }), ) : publishedCondition(published); + const access = await contentPublicAccessCondition(c, definition, { + actor: options?.actor, + }); const rows = await keepVisible( await c .get("db") .select(withParent(detail.selection)) .from(table) - .where(and(readable, inArray(primaryCursor, [...ids]))), + .where(and(readable, inArray(primaryCursor, [...ids]), access)), { hiddenParents: options?.hiddenParents }, ); return (await detail.hydrate(rows)).map(detail.project); }, - findMany: async ({ filters = {}, hidden, orderBy, query = {} } = {}) => { + findMany: async ({ + actor, + filters = {}, + hidden, + orderBy, + query = {}, + } = {}) => { const conditions = [ + // Before pagination, so a page, its cursor and its count only ever + // know about records this viewer may read. + await contentPublicAccessCondition(c, definition, { actor }), contentPublicListCondition({ advanced, columns, definition }, hidden), buildFilterCondition({ allowed: publicApi.filterableFields, diff --git a/packages/vitnode/src/content/server/public-visibility-routes.ts b/packages/vitnode/src/content/server/public-visibility-routes.ts index 56355e6f29..edf39062c6 100644 --- a/packages/vitnode/src/content/server/public-visibility-routes.ts +++ b/packages/vitnode/src/content/server/public-visibility-routes.ts @@ -22,6 +22,7 @@ import { import { contentEditorialEffects } from "./editorial-effects"; import { emitContentEvent } from "./emit"; import { withHttpErrors } from "./http-errors"; +import { canAccessContentRecord } from "../access/server/decide"; import { resetContentLiveRecord } from "./live/hooks"; import { contentPublicStateOf } from "./page-cache"; import { visibilityAction } from "./report-target"; @@ -29,19 +30,28 @@ import { contentIdentifier, jsonBody, readOptionalJson } from "./route-helpers"; import { syncContentSearch } from "./search-sync"; import { isContentBaseRowPublic } from "./visibility"; +/** + * The row, but only when the viewer may read it under the access rules of its + * scopes: an action on a record somebody may not read is the same 404 as on + * one that does not exist - moderators included, who act from the site with + * their own roles. + */ const readRow = async ( c: Context, - model: { columns: { id: unknown }; table: PgTable }, + model: AnyContentModel, id: ContentId, ): Promise | undefined> => { const [row] = (await c .get("db") .select() - .from(model.table) + .from(model.table as PgTable) .where(eq(model.columns.id as never, id)) .limit(1)) as Record[]; + if (!row) return undefined; - return row; + return (await canAccessContentRecord(c, { action: "read", id, model })) + ? row + : undefined; }; /** @@ -90,7 +100,7 @@ export const contentPublicVisibilityRoutes = < if (!user) throw new HTTPException(401, { message: "Sign in first." }); const id = contentIdentifier(c, definition); - const row = await readRow(c, model, id); + const row = await readRow(c, anyModel, id); if (!row || !isContentBaseRowPublic({ ...row, hiddenAt: null })) { throw new HTTPException(404, { message: `${name} not found.` }); } @@ -215,7 +225,7 @@ export const contentPublicDeleteRoute = < if (!user) throw new HTTPException(401, { message: "Sign in first." }); const id = contentIdentifier(c, definition); - const row = await readRow(c, model, id); + const row = await readRow(c, model as unknown as AnyContentModel, id); if (!row) throw new HTTPException(404, { message: `${name} not found.` }); if (!(await canDeleteContentRecord(c, { model, pluginId, row }))) { throw new HTTPException(403, { diff --git a/packages/vitnode/src/content/server/public-write-service.ts b/packages/vitnode/src/content/server/public-write-service.ts index 75e3566540..38a54032ef 100644 --- a/packages/vitnode/src/content/server/public-write-service.ts +++ b/packages/vitnode/src/content/server/public-write-service.ts @@ -8,6 +8,8 @@ import type { Context } from "hono"; import { eq, getColumns, getColumnTable, inArray } from "drizzle-orm"; import { getTableConfig } from "drizzle-orm/pg-core"; +import type { ContentAccessAction } from "../access/policy"; +import type { ContentAccessScopeRef } from "../access/server/source"; import type { ContentSubmissionRow } from "../approvals/server/submissions"; import type { EffectiveContentContainerPolicy } from "../containers/policies"; import type { ContentRecordContainer } from "../containers/server/policy"; @@ -19,7 +21,7 @@ import type { ContentPublicUpdateBody, ContentPublicWriteRecord, } from "../types"; -import type { ContentModel } from "./model"; +import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPublicWriteActor } from "./public-write-authorization"; import type { ContentDatabase } from "./service"; @@ -51,6 +53,12 @@ import { ContentPublicWriteInputError, ContentVersionConflict, } from "../errors"; +import { decideContentAccessForScopes } from "../access/server/decide"; +import { + contentAccessScopesForValues, + contentAccessSourceOf, + contentRecordAccessScopes, +} from "../access/server/source"; import { contentIdKey, requireContentId } from "../ids"; import { isContentReferenceCollection } from "../paths"; import { canViewHiddenContentRecord } from "./hidden-access"; @@ -194,6 +202,39 @@ export const createContentPublicWriteService = < issue: ContentPublicWriteDenied["issue"] = null, ) => new ContentPublicWriteDenied({ contentTypeId, issue, reason }); + const accessModel = model as unknown as AnyContentModel; + // A reply is written under its parent's `comment` rule; anything else needs + // to read the scope it is written into. + const writeAction = (): ContentAccessAction => + contentAccessSourceOf(c, accessModel)?.kind === "parent" + ? "comment" + : "read"; + + /** + * The access rules of the scopes a write lands in, decided from the + * database for this request - so a role taken away a moment ago is already + * gone, whatever form the member still has open. A scope they may not even + * see is a 404, like a record that does not exist. + */ + const assertWriteAccess = async ( + scopes: ContentAccessScopeRef[] | null | undefined, + ): Promise => { + if (!scopes) throw deny("notFound"); + if (scopes.length === 0) return; + + const action = writeAction(); + const decision = await decideContentAccessForScopes(c, { action, scopes }); + if (decision.allowed) return; + + const visible = await decideContentAccessForScopes(c, { + action: "view", + scopes, + }); + throw visible.allowed + ? deny("forbidden", CONTENT_PUBLIC_WRITE_ISSUE_CODES.accessDenied) + : deny("notFound"); + }; + const requireActor = (): ContentPublicWriteActor => { const actor = resolveContentPublicWriteActor(c); if (!actor) throw deny("unauthenticated"); @@ -421,6 +462,12 @@ export const createContentPublicWriteService = < ) => { if (!row) throw deny("notFound"); + await assertWriteAccess( + ( + await contentRecordAccessScopes(c, accessModel, [row.id as ContentId]) + ).get(contentIdKey(row.id as ContentId)), + ); + const container = await resolveContentRowContainer(c, tx, definition, row); const policy = effectiveContentPolicy(definition, container); const decision = await authorizeContentPublicWrite(c, { @@ -516,6 +563,9 @@ export const createContentPublicWriteService = < const prepare = async (tx: ContentDatabase) => { await assertPublicRelationTargets(tx, parsed); + await assertWriteAccess( + await contentAccessScopesForValues(c, accessModel, parsed), + ); const container = await asInputError( async () => await resolveContentWriteContainer(c, tx, definition, parsed), diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index 39ef897364..7028c254c8 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -83,6 +83,10 @@ import { zodContentReferenceListItem, zodContentReferenceListItemAnyId, } from "./list-references"; +import { + buildContentAccessAdminRoutes, + buildContentAccessEffectiveRoute, +} from "../access/server/admin-routes"; import { buildContentLiveRoutes } from "./live-routes"; import { commitContentDrafts } from "./live-store"; import { resetContentLiveRecord } from "./live/hooks"; @@ -2100,6 +2104,25 @@ export const buildContentRoutes = < : []), // Mounted under the same module and the same permissions, so a localized // content type gets its translation routes without a second registration. + // A scope content type (a blog category) gets the same access editor + // routes a container collection has; one filed under scopes gets the + // effective-access check its editor shows. + ...(definition.access.scope.enabled + ? buildContentAccessAdminRoutes({ + familyId: definition.id, + permissionModule: definition.permissionModule, + pluginId, + }) + : []), + ...(definition.access.from + ? [ + buildContentAccessEffectiveRoute({ + contentTypeId: definition.id, + permissionModule: definition.permissionModule, + pluginId, + }), + ] + : []), ...(definition.localization.enabled ? [ ...buildContentTranslationRoutes(model, { pluginId }), diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts index 3b66f5dc7b..65831e664e 100644 --- a/packages/vitnode/src/content/types.ts +++ b/packages/vitnode/src/content/types.ts @@ -21,6 +21,7 @@ import type { CONTENT_VISIBILITY_FIELDS, CONTENT_VISIBILITY_FILTERS, } from "./const"; +import type { ResolvedContentTypeAccess } from "./define-access"; import type { AnyContentContainerDefinition } from "./containers/types"; import type { ContentFileDescriptor } from "./files"; import type { @@ -2091,6 +2092,8 @@ export interface ContentTypeDefinition< TPublicPeople extends string = string, TPublicListField extends string = string, > { + /** Role-based access rules, or the disabled default when `access` is omitted. */ + access: ResolvedContentTypeAccess; admin: ResolvedContentAdminConfig; /** Generated junction tables, child tables and the leaf-path mapping. */ advanced: ResolvedContentAdvancedConfig; diff --git a/packages/vitnode/src/database/content-access.ts b/packages/vitnode/src/database/content-access.ts new file mode 100644 index 0000000000..3c6bff5262 --- /dev/null +++ b/packages/vitnode/src/database/content-access.ts @@ -0,0 +1,58 @@ +import { camelCase, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; + +import { core_roles } from "./roles"; + +/** + * One access rule an administrator set on one scope: a container, a blog + * category, or any other scope a plugin declares. A scope with no row for an + * action inherits it. `family` is the container collection or content type + * id, `scopeId` the scope's own id as text. + */ +export const core_content_access_rules = camelCase.table.withRLS( + "core_content_access_rules", + t => ({ + id: t.serial().primaryKey(), + family: t.varchar({ length: 100 }).notNull(), + scopeId: t.varchar({ length: 64 }).notNull(), + action: t.varchar({ length: 32 }).notNull(), + mode: t.varchar({ length: 16 }).notNull(), + createdAt: t.timestamp().notNull().defaultNow(), + updatedAt: t + .timestamp() + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }), + t => [ + uniqueIndex("core_content_access_rules_scope_action_key").on( + t.family, + t.scopeId, + t.action, + ), + ], +); + +/** + * The roles a `roles` rule lets in. Deleting a role deletes its rows, and a + * `roles` rule with no row left lets nobody in - a deleted role never opens a + * restricted scope. + */ +export const core_content_access_rule_roles = camelCase.table.withRLS( + "core_content_access_rule_roles", + t => ({ + ruleId: t + .integer() + .notNull() + .references(() => core_content_access_rules.id, { + onDelete: "cascade", + }), + roleId: t + .integer() + .notNull() + .references(() => core_roles.id, { onDelete: "cascade" }), + }), + t => [ + primaryKey({ columns: [t.ruleId, t.roleId] }), + index("core_content_access_rule_roles_role_id_idx").on(t.roleId), + ], +); diff --git a/packages/vitnode/src/database/files.ts b/packages/vitnode/src/database/files.ts index 4b09a4fef8..4d1dc5e688 100644 --- a/packages/vitnode/src/database/files.ts +++ b/packages/vitnode/src/database/files.ts @@ -24,6 +24,13 @@ export const core_files = camelCase.table.withRLS( .varchar({ enum: ["automatic", "manual", "disabled"], length: 16 }) .notNull() .default("automatic"), + // `private` objects live in the storage adapter's private store and are + // served only through an authorized route; `public` ones have a public + // URL anybody holding it can open. + visibility: t + .varchar({ enum: ["public", "private"], length: 16 }) + .notNull() + .default("public"), createdAt: t.timestamp().notNull().defaultNow(), }), t => [ diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts index 8e635a0633..1ab852d5df 100644 --- a/packages/vitnode/src/database/relations.ts +++ b/packages/vitnode/src/database/relations.ts @@ -4,6 +4,7 @@ import * as admins from "./admins"; import * as ai from "./ai"; import * as audit from "./audit"; import * as content from "./content"; +import * as contentAccess from "./content-access"; import * as contentContainers from "./content-containers"; import * as contentSubmissions from "./content-submissions"; import * as cron from "./cron"; @@ -28,6 +29,7 @@ export const coreSchema = { ...ai, ...audit, ...content, + ...contentAccess, ...contentContainers, ...contentSubmissions, ...cron, diff --git a/packages/vitnode/src/database/search.ts b/packages/vitnode/src/database/search.ts index a5048950d1..1af020f7b8 100644 --- a/packages/vitnode/src/database/search.ts +++ b/packages/vitnode/src/database/search.ts @@ -101,6 +101,16 @@ export const core_search_index = camelCase.table.withRLS( ), containerType: t.varchar({ length: 100 }), containerId: t.integer(), + // The access scopes the item was filed in when it was indexed, as + // `family:scopeId` (`forum.forums:12`, `blog.category:3`). A query keeps a + // row only when every scope is one the viewer may read; the content + // tables are still checked before a result is returned, so a stale row + // never grants anything. + accessScopes: t + .text() + .array() + .notNull() + .default(sql`'{}'::text[]`), url: t.text(), isPublic: t.boolean().notNull().default(true), isHidden: t.boolean().notNull().default(false), @@ -130,6 +140,7 @@ export const core_search_index = camelCase.table.withRLS( index("core_search_index_is_public_idx").on(t.isPublic), index("core_search_index_image_id_idx").on(t.imageId), index("core_search_index_container_idx").on(t.containerType, t.containerId), + index("core_search_index_access_scopes_idx").using("gin", t.accessScopes), ], ); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index b37ddb54ab..ac52317d1e 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1774,6 +1774,7 @@ "duplicate": "Duplicate", "hide": "Hide", "unhide": "Unhide", + "access": "Access", "activity": "Activity" }, "create": { @@ -3071,6 +3072,103 @@ "customerId": "Customer id", "errorCode": "Error code" } + }, + "access": { + "loading": "Checking your access", + "sign_in": "Sign in", + "read": { + "guest": { + "title": "Sign in to read this", + "desc": "This part of the community is open to signed-in members only. Sign in and it might just open up for you." + }, + "member": { + "title": "You don't have access here", + "desc": "Your account can't read this yet. If you think it should, ask an administrator." + } + }, + "comment": { + "guest": { + "title": "Sign in to reply", + "desc": "Replies are for signed-in members. Sign in to join the conversation." + }, + "member": { + "title": "Replies are closed to you here", + "desc": "You can read this, but your account can't reply in this place." + } + }, + "attachment": { + "title": "An attachment is available", + "desc": "You do not have permission to download it." + }, + "admin": { + "action": "Access", + "title": "Access", + "desc": "Who may see {name}, read what is inside, reply and download attachments. Each rule can inherit from the parent - a child can be stricter than its parent, never more open.", + "submit": "Save access", + "success": "Access saved", + "successDesc": "New rules for {name} apply from the next request.", + "loadError": "The access rules could not be loaded.", + "retry": "Try again", + "inherit": "Inherit ({value})", + "this_scope": "this one", + "deleted_role": "a deleted role", + "roles_label": "Roles that may {action}", + "roles_desc": "Members with any of these roles, as a primary or secondary role. Pick at least one.", + "modes": { + "roles": "Selected roles" + }, + "audience": { + "everyone": "Everyone", + "members": "Signed-in members", + "roles": "Members with {roles}", + "nobody": "Nobody (no role left)" + }, + "actions": { + "view": { + "label": "View", + "desc": "See that it exists - its name and description in lists and breadcrumbs." + }, + "read": { + "label": "Read", + "desc": "Open and read what is filed in it, find it in search and Discover." + }, + "comment": { + "label": "Comment", + "desc": "Reply to what is filed in it. Signing in and every moderation rule still apply." + }, + "downloadAttachments": { + "label": "Download attachments", + "desc": "Download protected attachments of what readers can read." + } + }, + "source": { + "scope": "Now inherited from {name}.", + "default": "Now inherited from the collection's default.", + "fallback": "Now open to everyone - nothing above sets it." + }, + "effective": { + "title": "Effective access", + "open": "Everyone", + "self": "{audience} - set here or inherited for {scope}", + "ancestor": "{audience} for {action} - required by {scope}, a parent", + "prerequisite": "{audience} for {action} - needed first", + "needs": "Needs {action} first, so it is never wider than that." + }, + "warnings": { + "nobody": "Nobody but a root role can {action} here: the rule on {scope} has no role left." + }, + "descendants": "{count, plural, one {# scope inside follows these rules too.} other {# scopes inside follow these rules too.}}", + "attachments": { + "unsupported": "The storage adapter keeps files public, so downloads stay open to everyone. Configure private storage to restrict them." + }, + "errors": { + "invalidAccess": "One of the rules is not valid.", + "notFound": "It no longer exists.", + "unknownRole": "One of the roles no longer exists. Pick the roles again.", + "unsupportedStorage": "Downloads can't be restricted: the storage adapter keeps files public.", + "unknown": "Something went wrong. Try again in a moment." + } + } } }, "admin": { diff --git a/packages/vitnode/src/tanstack/admin/containers/route.tsx b/packages/vitnode/src/tanstack/admin/containers/route.tsx index 53306f524a..3310a0b8bf 100644 --- a/packages/vitnode/src/tanstack/admin/containers/route.tsx +++ b/packages/vitnode/src/tanstack/admin/containers/route.tsx @@ -19,6 +19,7 @@ import { requireAdminPermission } from "../screen"; export const ADMIN_CONTAINERS_NAMESPACES = [ "core.global", + "core.access", "core.containers", ] as const; diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx index dbe637c591..7938504b40 100644 --- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx +++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx @@ -11,6 +11,7 @@ import { LinkIcon, PencilIcon, SendIcon, + LockIcon, Trash2Icon, } from "lucide-react"; import React from "react"; @@ -52,6 +53,7 @@ import { contentVisibilityTransition, isContentHidden, } from "@/content/visibility"; +import { ContentAccessSheet } from "@/views/admin/views/access/access-sheet"; import { ContentDuplicateDialog } from "@/views/admin/views/content/actions/duplicate-dialog"; import { CONTENT_EDITORIAL_ACTION_IDS, @@ -299,6 +301,7 @@ const ACTION_ICONS: Record< Exclude, React.ReactNode > = { + access: , activity: , delete: , delivery: , @@ -508,6 +511,11 @@ const ContentRowActionsMenu = ({ permission: CONTENT_PERMISSIONS.delete, plugin: pluginId, }); + const canEdit = useAdminPermission({ + module, + permission: CONTENT_PERMISSIONS.edit, + plugin: pluginId, + }); const target = contentApiTarget(definition, pluginId); const visibility = contentVisibilityTransition({ @@ -515,8 +523,10 @@ const ContentRowActionsMenu = ({ }); const slots = contentAdminSlots(); const items = contentRowActionIds({ + access: definition.access.scope.enabled, canCreate, canDelete, + canEdit, canHide, canPublish, canView, @@ -647,6 +657,25 @@ const ContentRowActionsMenu = ({ /> ) : null} + {panel?.id === "access" ? ( + { + await invalidateContentList(queryClient, definition.id); + }} + open={panel.open} + scopeId={String(row.id)} + scopeName={title} + scopeNames={new Map([[String(row.id), title]])} + target={{ + family: definition.id, + module: `content/${definition.permissionModule}`, + pluginId, + }} + /> + ) : null} + {panel?.id === "delete" ? ( {t("compose.title")} {comments.isSessionPending ? (

+ + + {t("title")} + {form.scopeName} + + + {scopeId === null ? null : ( + + + + } + > + + + )} + + + ); +}; diff --git a/packages/vitnode/src/views/admin/views/containers/container-manager.tsx b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx index 2a1f0fd553..3cd921dcea 100644 --- a/packages/vitnode/src/views/admin/views/containers/container-manager.tsx +++ b/packages/vitnode/src/views/admin/views/containers/container-manager.tsx @@ -27,6 +27,7 @@ import { cn } from "cn"; import { FolderTreeIcon, PlusIcon, RotateCwIcon } from "lucide-react"; import { useReducedMotion } from "motion/react"; import React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; @@ -54,6 +55,7 @@ import { } from "@/lib/dnd/drag-motion"; import { FinePointerSensor } from "@/lib/dnd/sensors"; import { isAdminRequestError } from "@/views/admin/admin-request"; +import { ContentAccessSheet } from "@/views/admin/views/access/access-sheet"; import type { ContentContainerSave } from "./container-form-content"; import type { ContentContainerPoliciesSave } from "./container-policies-form"; @@ -68,7 +70,10 @@ import { ContentContainerPoliciesSheet, ContentContainerSheet, } from "./container-dialogs"; -import { contentContainerErrorKey } from "./container-query"; +import { + contentContainerErrorKey, + invalidateContentContainers, +} from "./container-query"; import { ContainerDragChip, type ContainerRowActions, @@ -260,6 +265,8 @@ export const ContentContainerManager = ({ const [sheetMode, setSheetMode] = React.useState<"create" | "edit">("create"); const deleting = useOverlay(); const policies = useOverlay(); + const access = useOverlay(); + const queryClient = useQueryClient(); const announcementContainer = React.useSyncExternalStore( subscribeToNothing, getAnnouncementContainer, @@ -469,6 +476,11 @@ export const ContentContainerManager = ({ const move = rootContainerMove(ordered, id); if (move) commitMove(move); }, + onAccess: definition.access.enabled + ? id => { + access.show({ id }); + } + : undefined, onPolicies: definition.policies.enabled && onSavePolicies ? id => { @@ -674,6 +686,10 @@ export const ContentContainerManager = ({ policies.id === null ? undefined : ordered.find(record => record.id === policies.id); + const accessContainer = + access.id === null + ? undefined + : ordered.find(record => record.id === access.id); const deletingContainer = deleting.id === null ? undefined @@ -716,6 +732,27 @@ export const ContentContainerManager = ({ /> ) : null} + {definition.access.enabled ? ( + { + await invalidateContentContainers(queryClient, definition.id); + }} + open={access.isOpen} + scopeId={accessContainer ? String(accessContainer.id) : null} + scopeName={accessContainer?.name ?? ""} + scopeNames={ + new Map(ordered.map(record => [String(record.id), record.name])) + } + target={{ + family: definition.id, + module: `content/${definition.admin.permissionModule}`, + pluginId, + }} + /> + ) : null} + void; onAddChild: (parentId: number) => void; onDelete: (id: number) => void; onEdit: (id: number) => void; @@ -92,8 +95,9 @@ const ContainerRowMenu = ({ }) => { const t = useTranslations("core.containers.list"); const tPolicies = useTranslations("core.containers.policies"); + const tAccess = useTranslations("core.access.admin"); const { id, name } = container; - const { onPolicies } = actions; + const { onAccess, onPolicies } = actions; const showMoves = permissions.canMove && (moves.canMoveUp || @@ -137,6 +141,16 @@ const ContainerRowMenu = ({ {tPolicies("action")} ) : null} + {permissions.canEdit && onAccess ? ( + { + onAccess(id); + }} + > + + {tAccess("action")} + + ) : null} {moves.canAddChild ? ( { diff --git a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts index ef0225689f..9510094d27 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/row-actions-model.ts @@ -8,6 +8,7 @@ export const CONTENT_ROW_ACTION_IDS = [ "delivery", "duplicate", "visibility", + "access", "delete", ] as const; @@ -16,6 +17,7 @@ export type ContentRowActionId = (typeof CONTENT_ROW_ACTION_IDS)[number]; export const CONTENT_LIST_ACTION_IDS = [ "duplicate", "visibility", + "access", "delete", ] as const satisfies readonly ContentRowActionId[]; @@ -38,8 +40,11 @@ export const isDestructiveContentRowAction = ( ): boolean => id === "delete"; export interface ContentRowActionInput { + /** `definition.access.scope.enabled` - each record is an access scope. */ + access?: boolean; canCreate?: boolean; canDelete: boolean; + canEdit?: boolean; canHide?: boolean; canPublish: boolean; canView: boolean; @@ -62,12 +67,15 @@ const gateOf = ( { canCreate = false, canDelete, + canEdit = false, canHide = false, canPublish, canView, }: ContentRowActionInput, ): boolean => { switch (id) { + case "access": + return canEdit && canView; case "delete": return canDelete; case "duplicate": @@ -84,6 +92,7 @@ const gateOf = ( const featureOf = ( id: ContentRowActionId, { + access = false, delivery, duplication = false, editorial, @@ -93,6 +102,8 @@ const featureOf = ( }: ContentRowActionInput, ): boolean => { switch (id) { + case "access": + return access; // Every content type is audited, so every one has a history to show. case "activity": case "delete": diff --git a/packages/vitnode/src/views/admin/views/content/content-labels.ts b/packages/vitnode/src/views/admin/views/content/content-labels.ts index a04343d4f8..d7d0149bfb 100644 --- a/packages/vitnode/src/views/admin/views/content/content-labels.ts +++ b/packages/vitnode/src/views/admin/views/content/content-labels.ts @@ -65,6 +65,7 @@ export const contentLabelsFrom = ( export const contentRouteNamespaces = (pluginId: string): string[] => [ "core.global", + "core.access", "core.content", pluginId, ]; diff --git a/plugins/blog/src/api/modules/categories/categories.module.ts b/plugins/blog/src/api/modules/categories/categories.module.ts index 8f87ead763..3ade3661f2 100644 --- a/plugins/blog/src/api/modules/categories/categories.module.ts +++ b/plugins/blog/src/api/modules/categories/categories.module.ts @@ -3,20 +3,28 @@ import type { ContentPublicHiddenAccess } from "@vitnode/core/content/server"; import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; +import type { ContentAccessFlags } from "@vitnode/core/content"; + import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache"; import { CONTENT_LOCALE_MAX_LENGTH, + contentAccessTag, contentLocalesMatch, + zodContentAccessFlags, } from "@vitnode/core/content"; import { + contentAccessFlagsForScopes, contentListHiddenAccess, + createContentAccess, + findContentAccessFamily, + isContentAccessFamilyOpen, contentListPageCacheTags, contentLocaleHeaders, listContentLanguages, rememberContentPublicRead, resolveContentRequestLocale, } from "@vitnode/core/content/server"; -import { asc, count, eq } from "drizzle-orm"; +import { and, asc, count, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { buildModule, buildRoute } from "@/api/builders"; @@ -33,6 +41,8 @@ const { definition } = categoryContent; /** One category as readers see it: never its translations, never its timestamps. */ export const zodBlogCategorySummary = z.object({ + /** What the viewer may do in the category, as its access rules decide. */ + access: zodContentAccessFlags, articleCount: z.number().int(), color: z.string().nullable(), id: z.number(), @@ -44,7 +54,7 @@ export const zodBlogCategorySummary = z.object({ type LocalizedBlogCategory = Omit< z.infer, - "articleCount" + "access" | "articleCount" >; interface BlogArticleCounts { @@ -110,8 +120,14 @@ const readArticleCounts = async ( locale: string | undefined, hidden?: ContentPublicHiddenAccess, ): Promise => { - const condition = await publicPostConditionIn(c, locale, hidden); - if (condition === null) return { byCategory: {}, total: 0 }; + const published = await publicPostConditionIn(c, locale, hidden); + if (published === null) return { byCategory: {}, total: 0 }; + // Only articles this viewer may read are counted: a restricted category's + // articles never show up in anybody else's totals. + const condition = and( + published, + await createContentAccess(c).condition(postContent, "read"), + ); const db = c.get("db"); const [rows, [totals]] = await Promise.all([ @@ -135,6 +151,35 @@ const readArticleCounts = async ( }; }; +const categoryFamily = (c: Context) => { + const family = findContentAccessFamily(c, definition.id); + if (!family) { + throw new Error("Blog categories are an access scope family."); + } + + return family; +}; + +/** Each category's flags for the viewer, keyed by category id. */ +const categoryFlags = async ( + c: Context, + ids: readonly number[], +): Promise> => { + const family = categoryFamily(c); + const entries = await Promise.all( + ids.map(async id => { + const { denied: _denied, ...flags } = await contentAccessFlagsForScopes( + c, + { scopes: [{ family: family.id, scopeId: String(id) }] }, + ); + + return [id, flags] as const; + }), + ); + + return new Map(entries); +}; + /** * Every blog category with its name in one language, its colour and how many * articles readers can open in it, plus the total for "All articles". Public: @@ -185,13 +230,20 @@ const publicCategoriesRoute = buildRoute({ model: postContent, pluginId: manifest.id, }); + const access = createContentAccess(c); + // Shared only when no access rule of a category or an article can change + // the answer; otherwise it is this viewer's own. + const shareable = + !hidden && + (await access.isOpen(postContent)) && + (await isContentAccessFamilyOpen(c, categoryFamily(c))); const [names, counts] = await Promise.all([ rememberContentPublicRead(c, { contentTypeId: definition.id, key: { ...key, route: "@vitnode/blog:category-summaries" }, load: async () => await readCategorySummaries(c, resolved.locale), }), - hidden + !shareable ? readArticleCounts(c, resolved.locale, hidden) : rememberContentPublicRead(c, { contentTypeId: postContent.definition.id, @@ -200,26 +252,43 @@ const publicCategoriesRoute = buildRoute({ }), ]); - setPageCacheTags(c, [ - ...contentListPageCacheTags(definition), - ...contentListPageCacheTags(postContent.definition), - ]); + if (shareable) { + setPageCacheTags(c, [ + ...contentListPageCacheTags(definition), + ...contentListPageCacheTags(postContent.definition), + contentAccessTag(definition.id), + ]); + } - return c.json( - { - items: names.map(category => ({ + // A category the viewer may not see is left out, name and all. + const flags = await categoryFlags( + c, + names.map(category => category.id), + ); + const items = names.flatMap(category => { + const categoryAccess = flags.get(category.id); + if (!categoryAccess?.canView) return []; + + return [ + { ...category, - articleCount: counts.byCategory[category.id] ?? 0, - })), - total: counts.total, - }, + access: categoryAccess, + articleCount: categoryAccess.canRead + ? (counts.byCategory[category.id] ?? 0) + : 0, + }, + ]; + }); + + return c.json( + { items, total: counts.total }, 200, - hidden - ? { + shareable + ? contentLocaleHeaders(resolved) + : { "Cache-Control": "private, no-store", ...contentLocaleHeaders(resolved), - } - : contentLocaleHeaders(resolved), + }, ); }, }); diff --git a/plugins/blog/src/api/modules/posts/posts.module.ts b/plugins/blog/src/api/modules/posts/posts.module.ts index 23c8a08885..cfedcb52aa 100644 --- a/plugins/blog/src/api/modules/posts/posts.module.ts +++ b/plugins/blog/src/api/modules/posts/posts.module.ts @@ -4,6 +4,7 @@ import { CONTENT_LOCALE_MAX_LENGTH, contentPublicListTag, } from "@vitnode/core/content"; +import { createContentAccess } from "@vitnode/core/content/server"; import { HTTPException } from "hono/http-exception"; import { buildModule, buildRoute } from "@/api/builders"; @@ -46,6 +47,12 @@ const postSlugRoute = buildRoute({ const post = await service.findById(id, { locale }); if (!post) throw new HTTPException(404, { message: "Article not found." }); + if (!(await createContentAccess(c).isOpen(postContent))) { + return c.json({ id: post.id, slug: post.friendlyUrl }, 200, { + "Cache-Control": "private, no-store", + }); + } + setPageCacheTags(c, [contentPublicListTag(postContent.definition.id)]); return c.json({ id: post.id, slug: post.friendlyUrl }, 200); diff --git a/plugins/blog/src/content/category.ts b/plugins/blog/src/content/category.ts index 05a53a2ce3..6b37b652c4 100644 --- a/plugins/blog/src/content/category.ts +++ b/plugins/blog/src/content/category.ts @@ -15,6 +15,11 @@ export const blogCategoryContentType = defineContentType({ id: "blog.category", tableName: "blog_categories", + // Every category is an access scope: who may see it, read its articles, + // comment on them and download their attachments. All open to everyone + // until an administrator restricts a category. + access: { scope: true }, + localization: { // The language every category is first written in. `en` is the locale // VitNode installs seed, and the boot guard says so loudly if an install diff --git a/plugins/blog/src/content/post.ts b/plugins/blog/src/content/post.ts index 08ca808146..276e082d89 100644 --- a/plugins/blog/src/content/post.ts +++ b/plugins/blog/src/content/post.ts @@ -32,6 +32,10 @@ export const blogPostContentType = defineContentType({ publication: true, + // An article is readable only when every one of its categories allows it. + // Comments under it follow the article without an option of their own. + access: { from: "categoryId" }, + duplication: true, visibility: true, diff --git a/plugins/blog/src/locales/en.json b/plugins/blog/src/locales/en.json index 40846b0a4b..74d4868dfc 100644 --- a/plugins/blog/src/locales/en.json +++ b/plugins/blog/src/locales/en.json @@ -225,7 +225,8 @@ "by": "By", "edited": "Edited", "reading_time": "{minutes} min read", - "on_this_page": "On this page" + "on_this_page": "On this page", + "access_required": "Members only" }, "comments": { "article_author": "Author", diff --git a/plugins/blog/src/pages/blog-category-page.tsx b/plugins/blog/src/pages/blog-category-page.tsx index 29e65251de..76714e189f 100644 --- a/plugins/blog/src/pages/blog-category-page.tsx +++ b/plugins/blog/src/pages/blog-category-page.tsx @@ -46,6 +46,9 @@ const loadBlogCategory = async ( }); } + // Nothing to list in a category the viewer may see but not read. + if (!selected.access.canRead) return { categories, posts: null, selected }; + const posts = await queryClient .query({ ...blogPostsQueryOptions({ diff --git a/plugins/blog/src/pages/blog-index.tsx b/plugins/blog/src/pages/blog-index.tsx index f691bde36d..62137c795f 100644 --- a/plugins/blog/src/pages/blog-index.tsx +++ b/plugins/blog/src/pages/blog-index.tsx @@ -10,7 +10,10 @@ import { EmptyTitle, } from "@vitnode/core/components/ui/empty"; import { useSessionQuery } from "@vitnode/core/tanstack/auth"; -import { ContentListPagination } from "@vitnode/core/tanstack/content"; +import { + ContentAccessRequired, + ContentListPagination, +} from "@vitnode/core/tanstack/content"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { FolderOpenIcon, @@ -161,6 +164,11 @@ export const BlogIndex = ({ }; const body = () => { + // A category the viewer may see but not read lists nothing at all. + if (selected && !selected.access.canRead) { + return ; + } + if (!posts) { return (

{selected - ? t("article_count", { count: selected.articleCount }) + ? selected.access.canRead + ? t("article_count", { count: selected.articleCount }) + : null : t("desc")}

diff --git a/plugins/blog/src/pages/post-comments-data.ts b/plugins/blog/src/pages/post-comments-data.ts index 32a068687a..91d1e16ea2 100644 --- a/plugins/blog/src/pages/post-comments-data.ts +++ b/plugins/blog/src/pages/post-comments-data.ts @@ -42,8 +42,11 @@ export type PostComment = z.infer; * routes: replies, editing with revisions, deleting, hiding and reports - and * kept current live, re-reading only the branches that changed. */ -export const usePostComments = (articleId: number) => +export const usePostComments = (articleId: number, canComment = true) => useContentComments({ + // Every category of the article has to allow comments; the server checks + // again when one is posted. + allowed: canComment, queryKey: blogQueryKeys.comments(articleId), maxDepth: BLOG_COMMENT_MAX_DEPTH, record: zodPostComment, diff --git a/plugins/blog/src/pages/post-comments.tsx b/plugins/blog/src/pages/post-comments.tsx index 61e94b9957..6ab2bff066 100644 --- a/plugins/blog/src/pages/post-comments.tsx +++ b/plugins/blog/src/pages/post-comments.tsx @@ -806,14 +806,17 @@ export const PostComments = ({ articleHref, articleId, articleTitle, + canComment = true, }: { articleAuthorIds: readonly number[]; articleHref: string; articleId: number; articleTitle: string; + /** Whether the article's categories let this reader comment. */ + canComment?: boolean; }) => { const t = useTranslations("core.global"); - const comments = usePostComments(articleId); + const comments = usePostComments(articleId, canComment); return (
({ items: [], total: 0 })), ]); - return { ...contentDeliveryPage(answer), categories: categories.items }; + if (answer.accessRequired) return { accessRequired: true as const }; + + const page = contentDeliveryPage(answer); + // Only the article's own categories count, all of them: commenting needs + // every one to allow it. A category missing from the list is one the reader + // may not see, and then the article would not have loaded at all. + const canComment = page.item.categoryId.every( + id => + categories.items.find(category => category.id === id)?.access + .canComment === true, + ); + + return { + ...page, + accessRequired: false as const, + canComment, + categories: categories.items, + }; }; type BlogPostPageData = Awaited>; -type BlogPostItem = BlogPostPageData["item"]; +type BlogPostArticleData = Extract; + +type BlogPostItem = BlogPostArticleData["item"]; export const route = defineRoute({ load: async ({ context, params }) => @@ -88,10 +109,15 @@ export const route = defineRoute({ slug: params.slug, }), - head: ({ loaderData }) => - contentDeliveryPageHead(loaderData?.metadata, { - title: loaderData?.item.title, - }), + head: ({ loaderData, t }) => + loaderData?.accessRequired + ? { + robots: "noindex, nofollow", + title: t("@vitnode/blog.post.access_required"), + } + : contentDeliveryPageHead(loaderData?.metadata, { + title: loaderData?.item.title, + }), }); const headingRenderer = @@ -222,9 +248,36 @@ const PostDates = ({ item }: { item: BlogPostItem }) => { ); }; -const BlogPostPage = ({ - loaderData: { categories, item, metadata }, -}: PluginRoutePageProps) => { +const BlogPostAccessRequired = () => { + const t = useTranslations("@vitnode/blog.post"); + + return ( +
+ + + + +
+ ); +}; + +const BlogPostPage = ({ loaderData }: PluginRoutePageProps) => + loaderData.accessRequired ? ( + + ) : ( + + ); + +const BlogPostArticle = ({ + loaderData: { canComment, categories, item, metadata }, +}: { + loaderData: BlogPostArticleData; +}) => { const t = useTranslations("@vitnode/blog.post"); const format = useFormatter(); const router = useRouter(); @@ -409,6 +462,7 @@ const BlogPostPage = ({ articleHref={blogPostHref(item.friendlyUrl)} articleId={item.id} articleTitle={item.title} + canComment={canComment} /> ) : null} diff --git a/plugins/blog/src/pages/post-query.ts b/plugins/blog/src/pages/post-query.ts index 5ac701b40c..f5530336d9 100644 --- a/plugins/blog/src/pages/post-query.ts +++ b/plugins/blog/src/pages/post-query.ts @@ -63,6 +63,8 @@ const fetchBlogPost = async ({ } return { + // In a category the viewer may see but not read: no article, only that. + accessRequired: detail.status === 403, item: detail.status === 200 ? await detail.json() : null, resolution: await resolution.json(), }; diff --git a/plugins/forum/src/api/modules/forum/stats.routes.ts b/plugins/forum/src/api/modules/forum/stats.routes.ts index 923a1ebef9..08babc5dda 100644 --- a/plugins/forum/src/api/modules/forum/stats.routes.ts +++ b/plugins/forum/src/api/modules/forum/stats.routes.ts @@ -4,10 +4,12 @@ import type { Context } from "hono"; import { z } from "@hono/zod-openapi"; import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache"; import { + contentAccessTag, contentContainerTag, contentPublicListTag, } from "@vitnode/core/content"; import { + createContentAccess, publicationColumns, publishedCondition, } from "@vitnode/core/content/server"; @@ -41,8 +43,41 @@ const statsTags = (): string[] => [ contentPublicListTag(topicContent.definition.id), contentPublicListTag(commentContent.definition.id), contentContainerTag(forumContainers.id), + contentAccessTag(forumContainers.id), ]; +/** + * The forums whose topics the viewer may read, or `null` for all of them. + * The cached stats are everybody's; each response keeps only these rows, so a + * restricted forum's counts and newest title never reach anyone else. + */ +export const readableForumIds = async ( + c: Context, +): Promise> => { + const allowed = await createContentAccess(c).allowedScopes( + forumContainers.id, + "read", + ); + + return allowed.all ? null : new Set(allowed.ids.map(Number)); +}; + +/** + * The headers of a stats response: shared (and page-cache tagged) only when + * no access rule can change it, `private, no-store` otherwise. + */ +export const forumStatsHeaders = async ( + c: Context, +): Promise> => { + if (!(await createContentAccess(c).isOpen(topicContent))) { + return { "Cache-Control": "private, no-store" }; + } + + setPageCacheTags(c, statsTags()); + + return {}; +}; + const json = (schema: T, description: string) => ({ content: { "application/json": { schema } }, description, @@ -130,12 +165,15 @@ export const forumStatsRoute = buildRoute({ commentCounts.map(row => [row.forumId, row.value]), ); const latestOf = new Map(latest.map(row => [row.forumId, row])); + const readable = await readableForumIds(c); - setPageCacheTags(c, statsTags()); + const headers = await forumStatsHeaders(c); return c.json( { - items: topicCounts.map(row => { + items: topicCounts + .filter(row => !readable || readable.has(row.forumId)) + .map(row => { const newest = latestOf.get(row.forumId); return { @@ -154,6 +192,7 @@ export const forumStatsRoute = buildRoute({ }), }, 200, + headers, ); }, }); @@ -205,6 +244,29 @@ export const loadTopicStats = async ( }); }; +/** The topics among `ids` the viewer may read, by their forum's rules now. */ +export const readableTopicIds = async ( + c: Context, + ids: readonly number[], +): Promise => { + const unique = [...new Set(ids)].filter(id => Number.isSafeInteger(id)); + if (unique.length === 0) return []; + const condition = await createContentAccess(c).condition( + topicContent, + "read", + ); + if (!condition) return unique; + + const topics = topicContent.table; + const rows = await c + .get("db") + .select({ id: topics.id }) + .from(topics) + .where(and(inArray(topics.id, unique), condition)); + + return rows.map(row => row.id); +}; + const TOPIC_IDS = new RegExp( `^[1-9]\\d{0,15}(,[1-9]\\d{0,15}){0,${FORUM_STATS_MAX_TOPICS - 1}}$`, ); @@ -237,11 +299,11 @@ export const topicStatsRoute = buildRoute({ }, handler: async c => { const ids = c.req.valid("query").ids.split(",").map(Number); - const items = await loadTopicStats(c, ids); + const items = await loadTopicStats(c, await readableTopicIds(c, ids)); - setPageCacheTags(c, statsTags()); + const headers = await forumStatsHeaders(c); - return c.json({ items }, 200); + return c.json({ items }, 200, headers); }, }); @@ -268,15 +330,28 @@ export const topicSlugRoute = buildRoute({ const { id } = c.req.valid("param"); const topics = topicContent.table; + const access = createContentAccess(c); const [row] = await c .get("db") .select({ id: topics.id, slug: topics.slug }) .from(topics) - .where(and(eq(topics.id, id), publicTopic())) + .where( + and( + eq(topics.id, id), + publicTopic(), + await access.condition(topicContent, "read"), + ), + ) .limit(1); if (!row) throw new HTTPException(404, { message: "Topic not found." }); + if (!(await access.isOpen(topicContent))) { + return c.json({ id: row.id, slug: row.slug }, 200, { + "Cache-Control": "private, no-store", + }); + } + setPageCacheTags(c, [contentPublicListTag(topicContent.definition.id)]); return c.json({ id: row.id, slug: row.slug }, 200); diff --git a/plugins/forum/src/api/modules/forum/topics.routes.ts b/plugins/forum/src/api/modules/forum/topics.routes.ts index dfe6268df8..1fd580f1f4 100644 --- a/plugins/forum/src/api/modules/forum/topics.routes.ts +++ b/plugins/forum/src/api/modules/forum/topics.routes.ts @@ -1,22 +1,20 @@ import { z } from "@hono/zod-openapi"; -import { setPageCacheTags } from "@vitnode/core/api/lib/page-cache"; import { zodPaginationPageInfo, zodPaginationQuery, } from "@vitnode/core/api/lib/with-pagination"; import { - contentContainerTag, - contentPublicListTag, + CONTENT_ACCESS_REQUIRED_CODE, zodContentPublicPerson, } from "@vitnode/core/content"; +import { createContentAccess } from "@vitnode/core/content/server"; import { HTTPException } from "hono/http-exception"; import { buildRoute } from "@/api/builders"; import { forumContainers } from "@/content/forums"; -import { commentContent } from "@/database/comments"; import { topicContent } from "@/database/topics"; -import { iso, loadTopicStats } from "./stats.routes"; +import { forumStatsHeaders, iso, loadTopicStats } from "./stats.routes"; /** * One page of a forum's topics with their comment stats, for the forum page. @@ -63,6 +61,18 @@ export const forumTopicsRoute = buildRoute({ description: "A page of topics, newest first", }, 400: { description: "Invalid query parameters" }, + 403: { + content: { + "application/json": { + schema: z.object({ + accessRequired: z.literal(true), + code: z.literal(CONTENT_ACCESS_REQUIRED_CODE), + }), + }, + }, + description: "The viewer may see the forum but not read its topics", + }, + 404: { description: "No forum the viewer may see" }, }, }, handler: async c => { @@ -72,6 +82,20 @@ export const forumTopicsRoute = buildRoute({ throw new HTTPException(500, { message: "Topics have no public API." }); } + // A forum the viewer may not see is not found; one they may see but not + // read says so, without listing anything in it. + const access = createContentAccess(c); + if (!(await access.canScope(forumContainers.id, forumId, "view"))) { + throw new HTTPException(404, { message: "Forum not found." }); + } + if (!(await access.canScope(forumContainers.id, forumId, "read"))) { + return c.json( + { accessRequired: true as const, code: CONTENT_ACCESS_REQUIRED_CODE }, + 403, + { "Cache-Control": "private, no-store" }, + ); + } + const { edges, pageInfo } = await service.findMany({ filters: { forum: forumId }, query: { cursor, first, last, page }, @@ -85,11 +109,7 @@ export const forumTopicsRoute = buildRoute({ ).map(item => [item.topicId, item]), ); - setPageCacheTags(c, [ - contentPublicListTag(topicContent.definition.id), - contentPublicListTag(commentContent.definition.id), - contentContainerTag(forumContainers.id), - ]); + const headers = await forumStatsHeaders(c); return c.json( { @@ -110,6 +130,7 @@ export const forumTopicsRoute = buildRoute({ pageInfo, }, 200, + headers, ); }, }); diff --git a/plugins/forum/src/content/forums.ts b/plugins/forum/src/content/forums.ts index 34912a67bb..1596b7f1fb 100644 --- a/plugins/forum/src/content/forums.ts +++ b/plugins/forum/src/content/forums.ts @@ -9,6 +9,11 @@ export const forumContainers = defineContentContainer({ }, hierarchy: { maxDepth: 4 }, + + // Per-forum view, read, reply and download rules, set in the AdminCP. Every + // action starts open to everyone; replying still needs a signed-in member, + // as it always did. + access: true, ordering: { mode: "manual" }, policies: { diff --git a/plugins/forum/src/locales/en.json b/plugins/forum/src/locales/en.json index bd61b2bfc1..d351b0d1bd 100644 --- a/plugins/forum/src/locales/en.json +++ b/plugins/forum/src/locales/en.json @@ -95,7 +95,9 @@ "parent_unavailable": "That comment can no longer be answered. Reply higher up in the thread.", "topic_unavailable": "This topic no longer accepts comments." } - } + }, + "access_required": "Members only", + "members_only": "Restricted" }, "topics": { "form": { diff --git a/plugins/forum/src/pages/forum-list.tsx b/plugins/forum/src/pages/forum-list.tsx index 11d3effa0c..0cdb2298b2 100644 --- a/plugins/forum/src/pages/forum-list.tsx +++ b/plugins/forum/src/pages/forum-list.tsx @@ -1,6 +1,7 @@ import type { ContentContainerTreeNode } from "@vitnode/core/content"; import { HrefLink } from "@vitnode/core/tanstack/link"; +import { LockIcon } from "lucide-react"; import { useFormatter, useTranslations } from "use-intl"; import type { Forum, ForumStats } from "./forum-data"; @@ -18,6 +19,7 @@ const ForumRow = ({ const t = useTranslations("@vitnode/forum.forums"); const format = useFormatter(); const latest = stats?.latestTopic ?? null; + const locked = !forum.access.canRead; return (
@@ -31,6 +33,12 @@ const ForumRow = ({ > {forum.name} + {locked ? ( + + + ) : null} {forum.metadata.description ? (

@@ -40,6 +48,7 @@ const ForumRow = ({ + {locked ? null : (

{t("stats.topics")}
@@ -54,7 +63,9 @@ const ForumRow = ({
+ )} + {locked ? null : (
{latest ? ( <> @@ -75,6 +86,7 @@ const ForumRow = ({ {t("no_topics_yet")} )}
+ )}
); }; diff --git a/plugins/forum/src/pages/forum-page.tsx b/plugins/forum/src/pages/forum-page.tsx index c11d15bfd4..8af8f52fe9 100644 --- a/plugins/forum/src/pages/forum-page.tsx +++ b/plugins/forum/src/pages/forum-page.tsx @@ -18,7 +18,10 @@ import { EmptyTitle, } from "@vitnode/core/components/ui/empty"; import { LOADER_DATA_QUERY_META } from "@vitnode/core/lib/query-client"; -import { contentPageItem } from "@vitnode/core/tanstack/content"; +import { + ContentAccessRequired, + contentPageItem, +} from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; @@ -96,13 +99,18 @@ const loadForum = async ( await queryForum(queryClient, slug), ); + // A forum the viewer may see but not read lists nothing - not even how + // many topics it holds. + const canRead = container.access.canRead; const [{ nextCursor, topics }, forumStats] = await Promise.all([ - queryClient.query( - forumTopicsQueryOptions({ - forumId: container.id, - ...(cursor ? { cursor } : {}), - }), - ), + canRead + ? queryClient.query( + forumTopicsQueryOptions({ + forumId: container.id, + ...(cursor ? { cursor } : {}), + }), + ) + : Promise.resolve({ nextCursor: null, topics: [] }), children.length > 0 ? queryClient.query(forumStatsQueryOptions()) : Promise.resolve([]), @@ -110,6 +118,7 @@ const loadForum = async ( return { ancestors, + canRead, children, container, forumStats, @@ -244,19 +253,28 @@ const ForumPage = ({ loaderData, }: PluginRoutePageProps) => { const t = useTranslations("@vitnode/forum.forums"); - const { children, container, forumStats, isFirstPage, nextCursor, topics } = - loaderData; + const { + canRead, + children, + container, + forumStats, + isFirstPage, + nextCursor, + topics, + } = loaderData; return ( -