diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9ded03f..519ec93 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -188,6 +188,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/access-rules.spec.ts + - name: Reset login rate limit before public pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + - name: Run public pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts + - name: Reset login rate limit before offline pack run: | echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 173d11c..7a24d9c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -19,6 +19,7 @@ import { PagesModule } from './pages/pages.module'; import { PermissionsModule } from './permissions/permissions.module'; import { PondsModule } from './ponds/ponds.module'; import { PrismaModule } from './prisma/prisma.module'; +import { PublicModule } from './public/public.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; import { SearchModule } from './search/search.module'; import { SettingsModule } from './settings/settings.module'; @@ -46,6 +47,7 @@ import { VersionsModule } from './versions/versions.module'; SearchModule, GrantsModule, MembersModule, + PublicModule, AuthModule, AdminModule, LoggerModule.forRootAsync({ diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts index 89c78eb..ae33c68 100644 --- a/apps/api/src/files/files.controller.ts +++ b/apps/api/src/files/files.controller.ts @@ -16,7 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared'; import type { Response } from 'express'; -import { AuthedRequest } from '../auth/auth.guard'; +import { AuthedRequest, Public } from '../auth/auth.guard'; import { RequiresAttachmentPermission, RequiresPondRole, @@ -41,15 +41,21 @@ export class FilesController { return this.files.upload(request.user!, pondId, file); } - /** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */ + /** + * Permission-checked file streaming (ADR 0011) — never served same-origin as + * executable content. `@Public()` so embedded images on a public page load + * for anonymous visitors (issue #56); the guard still resolves the `public` + * grant via the attachment's page and 404s otherwise. + */ @Get('media/:fileId') + @Public() @RequiresAttachmentPermission('read', { idParam: 'fileId' }) async download( @Param('fileId') fileId: string, @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { - const { attachment, stream } = await this.files.download(request.user!, fileId); + const { attachment, stream } = await this.files.download(request.user ?? null, fileId); response.set('X-Content-Type-Options', 'nosniff'); // Attachments are immutable — a new upload always gets a new id. response.set('Cache-Control', 'private, max-age=31536000, immutable'); diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 2342a22..75746cd 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -104,7 +104,7 @@ export class FilesService { } } - async download(_user: User, id: string): Promise { + async download(_user: User | null, id: string): Promise { const attachment = await this.prisma.attachment.findFirst({ where: { id } }); if (!attachment) throw new NotFoundException(); return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) }; diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts new file mode 100644 index 0000000..c95a0a5 --- /dev/null +++ b/apps/api/src/public/public.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Param, Req, Res } from '@nestjs/common'; +import type { Response } from 'express'; + +import { AuthedRequest, Public } from '../auth/auth.guard'; +import { PublicPageContent, PublicService } from './public.service'; + +/** + * Public read endpoints (issue #56). `@Public()` so anonymous visitors reach + * them; the service enforces the `public` grant through the shared resolver and + * 404s otherwise (non-public pages never leak). Two shapes: JSON for the SPA's + * read-only view, and a self-contained HTML document for crawlers / PDF export. + */ +@Controller('public') +export class PublicController { + constructor(private readonly publicPages: PublicService) {} + + @Get(':pondSlug/:pageSlug/content') + @Public() + async content( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: AuthedRequest, + ): Promise { + return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); + } + + @Get(':pondSlug/:pageSlug') + @Public() + async html( + @Param('pondSlug') pondSlug: string, + @Param('pageSlug') pageSlug: string, + @Req() request: AuthedRequest, + @Res({ passthrough: true }) response: Response, + ): Promise { + const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`; + const html = await this.publicPages.html(request.user ?? null, pondSlug, pageSlug, canonical); + response.set('Content-Type', 'text/html; charset=utf-8'); + return html; + } +} diff --git a/apps/api/src/public/public.e2e.db.test.ts b/apps/api/src/public/public.e2e.db.test.ts new file mode 100644 index 0000000..0e56319 --- /dev/null +++ b/apps/api/src/public/public.e2e.db.test.ts @@ -0,0 +1,126 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { PondPermissionCache } from '../permissions/pond-permission-cache'; +import { createTestApp } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; + +/** + * Public read access end to end (issue #56): an anonymous request reads a page + * a `public` grant opens, via both the JSON and HTML endpoints; removing the + * grant 404s both, and a non-public page never resolves. Requests carry no + * session cookie. + */ +describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + + let ownerId: string; + let pondSlug: string; + let pondId: string; + let pageSlug: string; + let privatePondSlug: string; + let privatePageSlug: string; + let publicGrantId: string; + + const api = () => request(app.getHttpServer()); + + async function makePage(pondIdV: string, slug: string, title: string, html: string) { + const page = await prisma.page.create({ + data: { + pondId: pondIdV, + slug, + title, + createdBy: ownerId, + sortKey: 'a0', + ydocState: new Uint8Array(), + contentCache: { create: { plainText: title, markdown: title, html, outline: [] } }, + }, + }); + return page; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + app = await createTestApp(); + const owner = await prisma.user.create({ + data: { + username: `pub-owner-${suffix}`, + email: `pub-owner-${suffix}@example.test`, + displayName: 'Pub Owner', + }, + }); + ownerId = owner.id; + + pondSlug = `pub-pond-${suffix}`; + const pond = await prisma.pond.create({ + data: { slug: pondSlug, name: 'Public Pond', type: 'SHARED', ownerId }, + }); + pondId = pond.id; + pageSlug = `welcome-${suffix}`; + await makePage(pondId, pageSlug, 'Welcome', '

Hello world from a public page.

'); + const grant = await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'PUBLIC', + subjectId: null, + role: 'READER', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + publicGrantId = grant.id; + + privatePondSlug = `priv-pond-${suffix}`; + const priv = await prisma.pond.create({ + data: { slug: privatePondSlug, name: 'Private Pond', type: 'SHARED', ownerId }, + }); + privatePageSlug = `secret-${suffix}`; + await makePage(priv.id, privatePageSlug, 'Secret', '

Nobody public should read this.

'); + }); + + afterAll(async () => { + await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } }); + await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } }); + await prisma.page.deleteMany({ where: { pond: { ownerId } } }); + await prisma.pond.deleteMany({ where: { ownerId } }); + await prisma.user.deleteMany({ where: { id: ownerId } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('serves a public page as HTML and JSON to an anonymous visitor', async () => { + const html = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200); + expect(html.headers['content-type']).toContain('text/html'); + expect(html.text).toContain(''); + expect(html.text).toContain('Welcome'); + expect(html.text).toContain('Hello world from a public page.'); + expect(html.text).toContain('rel="canonical"'); + // No session-dependent content leaked into the crawler document. + expect(html.text).not.toContain('dt_session'); + + const json = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(200); + expect(json.body).toMatchObject({ title: 'Welcome', pondName: 'Public Pond' }); + expect((json.body as { html: string }).html).toContain('Hello world'); + }); + + it('never resolves a non-public page for an anonymous visitor', async () => { + await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}`).expect(404); + await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404); + await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404); + }); + + it('404s both endpoints once the public grant is removed', async () => { + await prisma.roleGrant.delete({ where: { id: publicGrantId } }); + // The API route invalidates on its own mutations; this test deletes + // directly, so drop the cached pond context to mirror that (issue #39). + app.get(PondPermissionCache).invalidate(pondId); + await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404); + await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404); + }); +}); diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts new file mode 100644 index 0000000..d4e1695 --- /dev/null +++ b/apps/api/src/public/public.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { PublicController } from './public.controller'; +import { PublicService } from './public.service'; + +/** + * Public read access (issue #56): anonymous-reachable page endpoints on top of + * the shared permission resolver (PermissionService comes from the global + * PermissionsModule). Media for public pages is served by FilesModule, which + * marks `GET /media/:fileId` public too. + */ +@Module({ + controllers: [PublicController], + providers: [PublicService], +}) +export class PublicModule {} diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts new file mode 100644 index 0000000..84109aa --- /dev/null +++ b/apps/api/src/public/public.service.ts @@ -0,0 +1,131 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Pond, User } from '@prisma/client'; + +import { PermissionService } from '../permissions/permission.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** The JSON the SPA renders for an anonymous (or any) reader of a public page. */ +export interface PublicPageContent { + pondName: string; + pondSlug: string; + title: string; + slug: string; + /** Pre-rendered body HTML from the content cache (issue #24). */ + html: string; + updatedAt: string; +} + +interface ResolvedPage { + pond: Pond; + page: { id: string; pondId: string; slug: string; title: string }; +} + +/** + * Public read access (issue #56): resolves a pond/page by slug and enforces + * read permission for the (possibly anonymous) viewer through the shared + * resolver — a `public` grant is what lets a logged-out visitor in. Denied or + * missing → 404, so non-public pages never reveal their existence + * (security.md). Serves both the SPA's JSON and a server-rendered HTML page for + * crawlers and the PDF exporter (ADR 0005/0009). + */ +@Injectable() +export class PublicService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionService, + ) {} + + private async resolve( + user: User | null, + pondSlug: string, + pageSlug: string, + ): Promise { + const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } }); + if (!pond) throw new NotFoundException(); + const page = await this.prisma.page.findFirst({ + where: { pondId: pond.id, slug: pageSlug, deletedAt: null }, + select: { id: true, pondId: true, slug: true, title: true }, + }); + // Hide existence: no read access (incl. anonymous without a public grant) → 404. + if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) { + throw new NotFoundException(); + } + return { pond, page }; + } + + /** The page content for the SPA's read-only public view. */ + async content(user: User | null, pondSlug: string, pageSlug: string): Promise { + const { pond, page } = await this.resolve(user, pondSlug, pageSlug); + const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); + return { + pondName: pond.name, + pondSlug: pond.slug, + title: page.title, + slug: page.slug, + html: resolveMediaUrls(cache?.html ?? ''), + updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), + }; + } + + /** A complete, self-contained HTML document for crawlers / PDF export. */ + async html( + user: User | null, + pondSlug: string, + pageSlug: string, + canonical: string, + ): Promise { + const content = await this.content(user, pondSlug, pageSlug); + const title = escapeHtml(`${content.title} — ${content.pondName}`); + // No session-dependent content: this document is identical for every viewer + // who may read the page (crawler-safe, cacheable). + return ` + + + + +${title} + + + + +
+

${escapeHtml(content.pondName)}

+

${escapeHtml(content.title)}

+${content.html} +
+ + +`; + } +} + +/** + * The cached HTML carries images as `` — in the live app + * the editor's node view resolves that to `/media/:fileId` client-side. The + * static public view has no such runtime, so resolve it here to a real `src` + * (the media endpoint is public too, issue #56). + */ +function resolveMediaUrls(html: string): string { + // Same URL the editor's image node view uses (apps/web/.../nodes/image.tsx). + return html.replace( + /data-file-id="([A-Za-z0-9-]+)"/g, + 'src="/api/v1/media/$1" data-file-id="$1"', + ); +} + +/** Minimal HTML escaping for the values we interpolate into the shell (not the + * already-sanitized cached body HTML). */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/web/e2e/public.spec.ts b/apps/web/e2e/public.spec.ts new file mode 100644 index 0000000..59d20c0 --- /dev/null +++ b/apps/web/e2e/public.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +/** + * Public read access (issue #56): an anonymous visitor reads a page a `public` + * grant opens, through the SPA's read-only view (no editor bundle), and its + * embedded image streams too; a non-public page never resolves. Uses the seeded + * `content-fixtures` pond (owned by fixture-user) whose "Fixture Image" page + * carries a real servable image. + */ + +async function pondId(owner: BrowserContext, slug: string): Promise { + const res = await owner.request.get(`/api/v1/ponds/${slug}`); + return ((await res.json()) as { id: string }).id; +} + +test('an anonymous visitor reads a public page and its image via the SPA', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const id = await pondId(owner, 'content-fixtures'); + const grant = await owner.request.post(`/api/v1/ponds/${id}/grants`, { + data: { subjectType: 'public', role: 'reader', scopeType: 'pond', effect: 'allow' }, + }); + const grantId = ((await grant.json()) as { id: string }).id; + + try { + const anon = await browser.newContext({ baseURL: BASE_URL }); // no session + const page = await anon.newPage(); + await page.goto('/public/content-fixtures/fixture-image'); + + // The read-only public view renders — with no collaborative editor. + await expect(page.locator('.public-page__badge')).toBeVisible(); + await expect(page.locator('.public-page__title')).toContainText('Fixture Image'); + await expect(page.locator('.ProseMirror')).toHaveCount(0); + + // The embedded image streams to the anonymous visitor (media honors public): + // fetch its resolved /media URL from the same session-less context. + const src = await page.locator('.public-page__body img').first().getAttribute('src'); + expect(src).toMatch(/^\/api\/v1\/media\//); + const media = await anon.request.get(src!); + expect(media.status()).toBe(200); + expect(media.headers()['content-type']).toContain('image/'); + + await anon.close(); + } finally { + await owner.request.delete(`/api/v1/ponds/${id}/grants/${grantId}`); + await owner.close(); + } +}); + +test('a non-public page never resolves for an anonymous visitor', async ({ browser }) => { + // A fresh private pond + page (no public grant) — isolated from any shared + // fixture pond so the negative case cannot be polluted by another test. + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const pond = (await ( + await owner.request.post('/api/v1/ponds', { data: { name: `Private ${Date.now()}` } }) + ).json()) as { id: string; slug: string }; + const page = (await ( + await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: 'Secret' } }) + ).json()) as { slug: string }; + + const anon = await browser.newContext({ baseURL: BASE_URL }); + const view = await anon.newPage(); + // The SPA shows "not found"… + await view.goto(`/public/${pond.slug}/${page.slug}`); + await expect(view.locator('.public-page__body')).toHaveCount(0); + // …and the content endpoint hides the page's existence. + const res = await anon.request.get(`/api/v1/public/${pond.slug}/${page.slug}/content`); + expect(res.status()).toBe(404); + + await anon.close(); + await owner.close(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 185a619..ce204ba 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,7 @@ import { NotFoundPage } from './pages/NotFoundPage'; import { PageEditorPage } from './pages/PageEditorPage'; import { PondHomePage } from './pages/PondHomePage'; import { PondSettingsPage } from './pages/PondSettingsPage'; +import { PublicPageView } from './pages/PublicPageView'; import { SettingsPage } from './pages/SettingsPage'; import { TrashPage } from './pages/TrashPage'; import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'; @@ -30,6 +31,8 @@ export function App(): React.JSX.Element { {/* Verify/reset work regardless of session state (mail links). */} } /> } /> + {/* Public read-only page view — reachable without a session (issue #56). */} + } /> }> } /> diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 2e441c4..4cc63b2 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -6,6 +6,7 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deLabels from '@dorfteich/shared/i18n/de/labels.json'; import deLinks from '@dorfteich/shared/i18n/de/links.json'; import deMembers from '@dorfteich/shared/i18n/de/members.json'; +import dePublic from '@dorfteich/shared/i18n/de/public.json'; import deSearch from '@dorfteich/shared/i18n/de/search.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json'; @@ -16,6 +17,7 @@ import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enLabels from '@dorfteich/shared/i18n/en/labels.json'; import enLinks from '@dorfteich/shared/i18n/en/links.json'; import enMembers from '@dorfteich/shared/i18n/en/members.json'; +import enPublic from '@dorfteich/shared/i18n/en/public.json'; import enSearch from '@dorfteich/shared/i18n/en/search.json'; import enSettings from '@dorfteich/shared/i18n/en/settings.json'; import i18n from 'i18next'; @@ -43,6 +45,7 @@ void i18n labels: enLabels, links: enLinks, members: enMembers, + public: enPublic, search: enSearch, }, de: { @@ -55,6 +58,7 @@ void i18n labels: deLabels, links: deLinks, members: deMembers, + public: dePublic, search: deSearch, }, }, diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx new file mode 100644 index 0000000..23d9981 --- /dev/null +++ b/apps/web/src/pages/PublicPageView.tsx @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +import { ApiError, apiGet } from '../lib/api'; +import { NotFoundPage } from './NotFoundPage'; + +interface PublicPageContent { + pondName: string; + pondSlug: string; + title: string; + slug: string; + html: string; + updatedAt: string; +} + +/** + * Read-only public page view (issue #56): renders a page that a `public` grant + * opens to anonymous visitors, from the server-derived HTML — deliberately + * WITHOUT importing the collaborative editor, so anonymous readers never load + * the editor bundle. A non-public page 404s (the api hides its existence). + */ +export function PublicPageView(): React.JSX.Element { + const { t } = useTranslation('public'); + const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); + + const query = useQuery({ + queryKey: ['public-page', pondSlug, pageSlug], + queryFn: () => apiGet(`/public/${pondSlug}/${pageSlug}/content`), + enabled: Boolean(pondSlug && pageSlug), + retry: false, + }); + + if (query.error instanceof ApiError && query.error.status === 404) return ; + if (query.isLoading || !query.data) return
; + + const page = query.data; + return ( +
+

{t('readOnlyBadge')}

+

{page.pondName}

+

{page.title}

+ {/* The HTML comes from the server's content cache (issue #24), derived + from the sanitized editor schema — safe to render. */} +
+
+ ); +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index c061823..0f1ff18 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1612,3 +1612,31 @@ button { gap: var(--space-2); padding: var(--space-1) 0; } + +/* Public read-only page view (issue #56) */ +.public-page { + max-width: 48rem; + margin: 0 auto; +} + +.public-page__badge { + display: inline-block; + padding: var(--space-1) var(--space-2); + border-radius: 999px; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + font-size: 0.8rem; + color: var(--color-text-muted); + margin-bottom: var(--space-3); +} + +.public-page__pond { + color: var(--color-text-muted); + font-size: 0.9rem; + margin: 0; +} + +.public-page__body img { + max-width: 100%; + height: auto; +} diff --git a/packages/shared/i18n/de/public.json b/packages/shared/i18n/de/public.json new file mode 100644 index 0000000..6785619 --- /dev/null +++ b/packages/shared/i18n/de/public.json @@ -0,0 +1,4 @@ +{ + "readOnlyBadge": "Öffentliche Seite · nur Lesen", + "signInToEdit": "Zum Bearbeiten anmelden" +} diff --git a/packages/shared/i18n/en/public.json b/packages/shared/i18n/en/public.json new file mode 100644 index 0000000..7e3ca39 --- /dev/null +++ b/packages/shared/i18n/en/public.json @@ -0,0 +1,4 @@ +{ + "readOnlyBadge": "Public page · read only", + "signInToEdit": "Sign in to edit" +}