diff --git a/.dockerignore b/.dockerignore index dc5a875..75f39a4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ **/*.php~ **/*.dist.php **/*.dist +!.env.dist **/*.cache **/._* **/.dockerignore @@ -28,6 +29,7 @@ tests/ var/ vendor/ .editorconfig +/.env .env.*.local .env.local .env.local.php diff --git a/.env.dist b/.env.dist index 27c88cd..9043aad 100644 --- a/.env.dist +++ b/.env.dist @@ -17,6 +17,8 @@ ###> symfony/framework-bundle ### APP_ENV=dev APP_SECRET=9e287f1ad737386dde46d51e80487236 +# Comma-separated CIDRs for reverse proxies (used when APP_ENV=prod). Override on the server if needed. +# TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,::1 ###< symfony/framework-bundle ### ###> docker ### # Dev URL: http://127.0.0.1:${HTTP_PORT}/ (override HTTP_PORT/HTTPS_PORT if busy). @@ -32,6 +34,11 @@ MYSQL_PASSWORD=password # Root password is only used for the bundled database service, see compose.yaml # skip it, if you use your own MYSQL_ROOT_PASSWORD=root_password +# Hub deploy: optional full image ref (default silberengel/unfold:latest in compose.hub.yaml). +# UNFOLD_DOCKER_IMAGE=silberengel/unfold:1.0.0 +# compose.hub.yaml: default host port is 9080. Use 80 only if nothing else binds it. Loopback-only example: +# HTTP_PUBLISH=127.0.0.1:9080 +# HTTP_PUBLISH=80 ###< docker ### ###> doctrine/doctrine-bundle ### diff --git a/Dockerfile b/Dockerfile index 83d7da2..bd0b287 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,7 +96,10 @@ RUN rm -Rf frankenphp/ RUN set -eux; \ mkdir -p var/cache var/log; \ + cp .env.dist .env; \ composer dump-autoload --classmap-authoritative --no-dev; \ composer dump-env prod; \ + rm -f .env; \ composer run-script --no-dev post-install-cmd; \ + php bin/console asset-map:compile --no-debug; \ chmod +x bin/console; sync; diff --git a/README.md b/README.md index 6692b23..97f98cb 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,60 @@ For development: docker compose build ``` -For production (using production overrides), set `APP_ENV=prod` in your `.env` file and run: +For production (using production overrides), set `APP_ENV=prod` in your `.env` file, set a strong **`APP_SECRET`**, and run: + ```bash docker compose -f compose.yaml -f compose.prod.yaml build ``` +`compose.override.yaml` is meant for local development. For production, always pass **both** compose files for `up` as well, otherwise Docker Compose still merges the dev override (FrankenPHP dev image, port `9080`, etc.): + +```bash +docker compose -f compose.yaml -f compose.prod.yaml up -d +``` + +The production compose file publishes the app on **host port `80`** → container `80` (FrankenPHP / Caddy). Put **TLS and your public hostname** (e.g. `https://blog.imwald.eu`) in front with Apache or nginx as a reverse proxy to `http://127.0.0.1:80` on that host (or another port if you change `compose.prod.yaml`). + +Set **`TRUSTED_PROXIES`** to the CIDR of your reverse proxy (defaults in `compose.prod.yaml` cover Docker and private nets; include the proxy’s address if it is elsewhere). In **`APP_ENV=prod`**, `config/packages/framework.yaml` enables **`trusted_proxies`** from that env var so Symfony trusts `X-Forwarded-Proto` / `X-Forwarded-For` from the proxy; adjust the value if generated URLs or secure cookies are wrong behind HTTPS. + +### Docker Hub (pre-built image) + +To build the production FrankenPHP image and push it (example registry: [`silberengel/unfold`](https://hub.docker.com/r/silberengel/unfold)): + +```bash +docker login +# If the server is linux/amd64 and your builder is ARM, set --platform (omit if arch matches). +docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:latest . +docker push silberengel/unfold:latest +``` + +Tag a release when you want a pinned version: + +```bash +docker tag silberengel/unfold:latest silberengel/unfold:1.0.0 +docker push silberengel/unfold:1.0.0 +``` + +**On the remote server** you only need `compose.hub.yaml`, a `.env` with at least **`APP_SECRET`** (and `MYSQL_*` / `MYSQL_ROOT_PASSWORD` if you use the bundled MySQL), and Docker Compose. Copy `compose.hub.yaml` from the repo (or clone once and take that file). The stack publishes the app on **host port `9080`** → container `80` by default (so **`:80` stays free** for Apache/nginx). Point your reverse proxy at `http://127.0.0.1:9080`. To bind only loopback, set **`HTTP_PUBLISH=127.0.0.1:9080`**; to use host port **80** instead, set **`HTTP_PUBLISH=80`**. Override the image with **`UNFOLD_DOCKER_IMAGE=myuser/unfold:1.0.0`** if you use another name or tag. + +```bash +docker compose -f compose.hub.yaml pull +docker compose -f compose.hub.yaml up -d +docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction +``` + +The production image must include **compiled asset mapper files** under `public/assets/` (the Docker build runs `asset-map:compile`). If you ever see JS modules blocked because the MIME type is `text/html`, the static files are missing: rebuild and push the image, or run once on the server: + +`docker compose -f compose.hub.yaml exec php php bin/console asset-map:compile --no-debug` + +The default `compose.hub.yaml` stack includes the **MySQL** service like the main compose file. If you use an external database, remove the `database` service and the `depends_on` block from `compose.hub.yaml`, and set **`DATABASE_URL`** in the `php` service `environment` to your connection string. + +**MySQL `1045 Access denied` for `unfold_user`:** The official MySQL image only applies **`MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_ROOT_PASSWORD` on the first start** of an empty data volume. If you change passwords in `.env` later, the files inside the **`database_data` volume** still hold the old users. Either set **`.env`** back to the **original** passwords, or stop the stack and remove the named volume (e.g. `docker compose -f compose.hub.yaml down` then `docker volume rm unfold_database_data` — **this deletes all DB data**), then **`up -d`** again with the passwords you want and run **migrations** again. + +The repo’s **`cron`** service still expects a local build and bind-mounted source; for Hub deploys, run **`articles:get`** (and any other jobs) from a host **cron** or **systemd timer** calling `docker compose -f compose.hub.yaml exec -T php php bin/console …`. + +### Start the Docker containers (development) -### Start the Docker containers ```bash docker compose up -d ``` @@ -68,6 +115,8 @@ Before fetching or displaying articles, make sure your database schema is up to docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction ``` +If you use **`compose.hub.yaml`**, prefix commands with `docker compose -f compose.hub.yaml` (for example `docker compose -f compose.hub.yaml exec php php bin/console …`). + ### Fetching Articles To fetch articles from the default relay for the last two months, run: diff --git a/compose.hub.yaml b/compose.hub.yaml new file mode 100644 index 0000000..11a25ad --- /dev/null +++ b/compose.hub.yaml @@ -0,0 +1,61 @@ +# Run a pre-built production image from Docker Hub (no local PHP image build). +# +# Usage on the server (copy this file + your .env, no app source required): +# docker compose -f compose.hub.yaml pull +# docker compose -f compose.hub.yaml up -d +# docker compose -f compose.hub.yaml exec php php bin/console doctrine:migrations:migrate --no-interaction +# +# Required in .env: APP_SECRET. Set MYSQL_* (or replace DATABASE_URL after editing this file) if you +# use the bundled database. For TLS in front, set TRUSTED_PROXIES to include your reverse proxy CIDR. +# +# Host HTTP port defaults to 9080 (same idea as local dev) so Apache/nginx can keep :80. Override with +# HTTP_PUBLISH=80 or HTTP_PUBLISH=127.0.0.1:9080 in .env if needed. +# +# Build & push (on your machine or CI), e.g.: +# docker build --platform linux/amd64 --target frankenphp_prod -t silberengel/unfold:latest . +# docker push silberengel/unfold:latest +# +# Override image: UNFOLD_DOCKER_IMAGE=myregistry/unfold:1.0.0 docker compose -f compose.hub.yaml up -d + +name: unfold + +services: + php: + image: ${UNFOLD_DOCKER_IMAGE:-silberengel/unfold:latest} + pull_policy: always + restart: unless-stopped + environment: + APP_ENV: ${APP_ENV:-prod} + APP_SECRET: ${APP_SECRET} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8} + SERVER_NAME: ${SERVER_NAME:-:80} + DATABASE_URL: mysql://${MYSQL_USER:-unfold_user}:${MYSQL_PASSWORD:-password}@database:3306/${MYSQL_DATABASE:-unfold_db}?serverVersion=${MYSQL_VERSION:-8.0}&charset=${MYSQL_CHARSET:-utf8mb4} + volumes: + - caddy_data:/data + - caddy_config:/config + ports: + - "${HTTP_PUBLISH:-9080}:80/tcp" + depends_on: + database: + condition: service_healthy + + database: + image: mysql:${MYSQL_VERSION:-8.0} + restart: unless-stopped + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE:-unfold_db} + MYSQL_USER: ${MYSQL_USER:-unfold_user} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-password} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password} + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - database_data:/var/lib/mysql:rw + +volumes: + caddy_data: + caddy_config: + database_data: diff --git a/composer.json b/composer.json index 4f05a0a..13d5660 100644 --- a/composer.json +++ b/composer.json @@ -102,7 +102,7 @@ "docker": true }, "runtime": { - "dotenv_overload": true + "dotenv_overload": false } }, "require-dev": { diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 6c84317..d8fb30f 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -10,11 +10,14 @@ framework: cookie_samesite: lax cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto'] - # trusted_proxies: '%env(TRUSTED_PROXIES)%' #trusted_proxies: 'symfony,REMOTE_ADDR' #esi: true #fragments: true +when@prod: + framework: + trusted_proxies: '%env(TRUSTED_PROXIES)%' + when@test: framework: test: true diff --git a/config/services.yaml b/config/services.yaml index 14f2457..4137d1b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,6 +8,8 @@ imports: parameters: footer_links: '%kernel.project_dir%/config/unfold.yaml' + # Default when TRUSTED_PROXIES is unset (override in .env / Compose for real deployments). + env(TRUSTED_PROXIES): '127.0.0.0/8,::1' services: # default configuration for services in *this* file diff --git a/frankenphp/docker-entrypoint.sh b/frankenphp/docker-entrypoint.sh index ab74418..ff7df98 100644 --- a/frankenphp/docker-entrypoint.sh +++ b/frankenphp/docker-entrypoint.sh @@ -16,7 +16,7 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then composer require "php:>=$PHP_VERSION" runtime/frankenphp-symfony composer config --json extra.symfony.docker 'true' - if grep -q ^DATABASE_URL= .env; then + if [ -f .env ] && grep -q ^DATABASE_URL= .env; then echo 'To finish the installation please press Ctrl+C to stop Docker Compose and run: docker compose up --build -d --wait' sleep infinity fi @@ -26,7 +26,8 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then composer install --prefer-dist --no-progress --no-interaction fi - if grep -q ^DATABASE_URL= .env; then + # DATABASE_URL from Compose / k8s env, or from a local .env file (dev bind-mount). + if [ -n "${DATABASE_URL:-}" ] || { [ -f .env ] && grep -q ^DATABASE_URL= .env; }; then echo 'Waiting for database to be ready...' ATTEMPTS_LEFT_TO_REACH_DATABASE=60 until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index d117e2e..fb16737 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -208,45 +208,64 @@ class ArticleController extends AbstractController CacheItemPoolInterface $articlesCache ): Response { $data = $request->getContent(); - // descriptor is an object with properties type, identifier and data - // if type === 'nevent', identifier is the event id - // if type === 'naddr', identifier is the naddr - // if type === 'nprofile', identifier is the npub $descriptor = json_decode($data); - $previewData = []; - // if nprofile, get from redis cache - if ($descriptor->type === 'nprofile') { - $hint = json_decode($descriptor->decoded); - $key = new Key(); - $npub = $key->convertPublicKeyToBech32($hint->pubkey); - $metadata = $cacheService->getMetadata($npub); - $metadata->npub = $npub; - $metadata->pubkey = $hint->pubkey; - $metadata->type = 'nprofile'; - // Render the NostrPreviewContent component with the preview data - $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ - 'preview' => $metadata - ]); - } else { - // For nevent or naddr, fetch the event data - try { - $previewData = $nostrClient->getEventFromDescriptor($descriptor); - $previewData->type = $descriptor->type; // Add type to the preview data - // Render the NostrPreviewContent component with the preview data - $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ - 'preview' => $previewData - ]); - } catch (\Exception $e) { - $html = 'Error fetching preview: ' . htmlspecialchars($e->getMessage()) . ''; - } + if (!\is_object($descriptor) || !isset($descriptor->type)) { + return new Response( + 'Invalid preview request.', + Response::HTTP_OK, + ['Content-Type' => 'text/html; charset=UTF-8'] + ); } + $html = ''; + + try { + if ($descriptor->type === 'nprofile') { + if (!isset($descriptor->decoded) || !\is_string($descriptor->decoded)) { + $html = 'Profile preview unavailable.'; + } else { + $hint = json_decode($descriptor->decoded); + if (!\is_object($hint) || !isset($hint->pubkey)) { + $html = 'Profile preview unavailable.'; + } else { + $key = new Key(); + $npub = $key->convertPublicKeyToBech32($hint->pubkey); + $metadata = $cacheService->getMetadata($npub); + $metadata->npub = $npub; + $metadata->pubkey = $hint->pubkey; + $metadata->type = 'nprofile'; + $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ + 'preview' => $metadata, + ]); + } + } + } elseif (!isset($descriptor->decoded)) { + $html = 'Preview unavailable (missing data).'; + } else { + try { + $previewData = $nostrClient->getEventFromDescriptor($descriptor); + } catch (\Throwable $e) { + $previewData = null; + $html = 'Error fetching preview: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').''; + } + if ($html === '' && $previewData === null) { + $html = 'No event found on the default relay for this preview.'; + } elseif ($html === '' && \is_object($previewData)) { + $previewData->type = $descriptor->type; + $html = $this->renderView('components/Molecules/NostrPreviewContent.html.twig', [ + 'preview' => $previewData, + ]); + } + } + } catch (\Throwable $e) { + $html = 'Preview error: '.htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').''; + } return new Response( $html, Response::HTTP_OK, - ['Content-Type' => 'text/html'] + ['Content-Type' => 'text/html; charset=UTF-8'] ); } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 8e917f4..9145b04 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Contracts\Cache\CacheInterface; +use Symfony\Contracts\Cache\ItemInterface; use Psr\Log\LoggerInterface; class DefaultController extends AbstractController @@ -33,17 +34,28 @@ class DefaultController extends AbstractController { $npub = $this->params->get('npub'); $dTag = $this->params->get('d_tag'); - // Key must match {@see Header} — `magazine_root_` avoids stale `null` entries from the old Header callback. - $cacheKey = 'magazine_root_'.$dTag; - $mag = $this->cache->get($cacheKey, function ($item) use ($npub, $dTag) { - $item->expiresAfter(300); // 5 minutes - return $this->nostrClient->getMagazineIndex($npub, $dTag); - }); + // Key must match {@see Header}. Throw from the cache callback when the index is missing so `null` + // is not stored under this key (same pattern as {@see CategoryLink} / per-category cache). + $cacheKey = 'magazine_root_v2_'.$dTag; + try { + $mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) { + $item->expiresAfter(300); + $mag = $this->nostrClient->getMagazineIndex($npub, $dTag); + if ($mag === null) { + throw new \RuntimeException('Magazine root index not found for '.$dTag); + } + + return $mag; + }); + } catch (\Throwable) { + return $this->render('home.html.twig', [ + 'indices' => [], + ]); + } - // Handle case when magazine is not found if ($mag === null) { return $this->render('home.html.twig', [ - 'indices' => [] + 'indices' => [], ]); } @@ -98,15 +110,16 @@ class DefaultController extends AbstractController } if (!empty($coordinates)) { - $slugs = array_map(function($coordinate) { - $parts = explode(':', $coordinate, 3); - return end($parts); + $slugs = array_map(static function ($coordinate) { + $parts = explode(':', (string) $coordinate, 3); + + return trim((string) end($parts)); }, $coordinates); - $slugs = array_filter($slugs); + $slugs = array_values(array_filter($slugs, static fn (string $s): bool => $s !== '')); $articles = $articleRepository->findBySlugsCriteria($slugs); $slugMap = []; foreach ($articles as $item) { - $slug = $item->getSlug(); + $slug = trim((string) $item->getSlug()); if ($slug !== '') { if (!isset($slugMap[$slug])) { $slugMap[$slug] = $item; @@ -119,9 +132,10 @@ class DefaultController extends AbstractController } } foreach ($coordinates as $coordinate) { - $parts = explode(':', $coordinate, 3); - if (isset($slugMap[end($parts)])) { - $list[] = $slugMap[end($parts)]; + $parts = explode(':', (string) $coordinate, 3); + $slugKey = trim((string) end($parts)); + if ($slugKey !== '' && isset($slugMap[$slugKey])) { + $list[] = $slugMap[$slugKey]; } } } diff --git a/src/Twig/Components/Header.php b/src/Twig/Components/Header.php index 445216b..ebb0334 100644 --- a/src/Twig/Components/Header.php +++ b/src/Twig/Components/Header.php @@ -26,13 +26,24 @@ class Header ) { $dTag = (string) $this->params->get('d_tag'); $npub = (string) $this->params->get('npub'); - // Same key as {@see DefaultController::index()} — must load the real index (not cache `null`). - $cacheKey = 'magazine_root_'.$dTag; - $mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) { - $item->expiresAfter(300); + // Same key as {@see DefaultController::index()}. If the relay returns nothing, throw from the + // callback so Symfony does not persist `null` — otherwise categories vanish until TTL (~5 min). + $cacheKey = 'magazine_root_v2_'.$dTag; + try { + $mag = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $dTag) { + $item->expiresAfter(300); + $mag = $this->nostrClient->getMagazineIndex($npub, $dTag); + if ($mag === null) { + throw new \RuntimeException('Magazine root index not found for '.$dTag); + } + + return $mag; + }); + } catch (\Throwable) { + $this->cats = []; - return $this->nostrClient->getMagazineIndex($npub, $dTag); - }); + return; + } if ($mag === null) { $this->cats = []; diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php index c39bd11..a0ae92a 100644 --- a/src/Twig/Components/Organisms/FeaturedList.php +++ b/src/Twig/Components/Organisms/FeaturedList.php @@ -70,7 +70,7 @@ final class FeaturedList } if (($tag[0] ?? null) === 'a' && isset($tag[1])) { $segs = explode(':', (string) $tag[1], 3); - $slugs[] = end($segs); + $slugs[] = trim((string) end($segs)); if (\count($slugs) >= 5) { break; } @@ -89,7 +89,7 @@ final class FeaturedList $slugMap = []; foreach ($articles as $article) { - $articleSlug = $article->getSlug(); + $articleSlug = trim((string) $article->getSlug()); if ($articleSlug !== '') { if (!isset($slugMap[$articleSlug])) { $slugMap[$articleSlug] = $article; @@ -101,7 +101,8 @@ final class FeaturedList $orderedList = []; foreach ($slugs as $articleSlug) { - if (isset($slugMap[$articleSlug])) { + $articleSlug = trim((string) $articleSlug); + if ($articleSlug !== '' && isset($slugMap[$articleSlug])) { $orderedList[] = $slugMap[$articleSlug]; } } diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php index 7e66090..6abac8b 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php @@ -9,8 +9,6 @@ use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Data\NAddr; use nostriphant\NIP19\Data\NEvent; use nostriphant\NIP19\Data\NProfile; -use nostriphant\NIP19\Data\NPub; - class NostrSchemeParser implements InlineParserInterface { @@ -38,9 +36,8 @@ class NostrSchemeParser implements InlineParserInterface switch ($decoded->type) { case 'npub': - /** @var NPub $decoded */ - $decoded = $decoded->data; - $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decoded->data->data)); + // Use the decoded bech32 (npub1…). NPub::$data is the hex pubkey; NostrMentionLink /author routes expect npub1… + $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $bechEncoded)); break; case 'nprofile': /** @var NProfile $decodedProfile */ diff --git a/templates/base.html.twig b/templates/base.html.twig index e422c52..7699e3b 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -4,7 +4,10 @@ {% block title %}{{ website_name }}{% endblock %} - + {# Unfurlers often use the first meta description; pages override this block. #} + {% block meta_description %} + + {% endblock %} @@ -14,7 +17,10 @@ {% block ogtags %} - + + + + {% endblock %} {% block javascripts %} {% block importmap %}{{ importmap('app') }}{% endblock %} diff --git a/templates/components/Organisms/FeaturedList.html.twig b/templates/components/Organisms/FeaturedList.html.twig index ae6962e..9d68004 100644 --- a/templates/components/Organisms/FeaturedList.html.twig +++ b/templates/components/Organisms/FeaturedList.html.twig @@ -1,6 +1,6 @@
{% if list %} -