From f4fe8e7035d3e024e355010148683acada8e1a66 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?=
Date: Sun, 11 May 2025 12:52:26 +0200
Subject: [PATCH] Moving things around, updating MD parser, content and static
pages
---
compose.yaml | 10 +-
composer.lock | 181 +++++------
config/packages/cache.yaml | 1 +
config/packages/doctrine.yaml | 6 +
config/services.yaml | 4 +
docker/cron/Dockerfile | 36 +++
docker/cron/README.md | 94 ++++++
docker/cron/crontab | 1 +
docker/cron/index_articles.sh | 8 +
migrations/Version20250509100039.php | 38 +++
migrations/Version20250509100408.php | 35 +++
src/Command/DatabaseCleanupCommand.php | 45 +++
src/Command/ElevateUserCommand.php | 52 ++++
src/Command/GetArticlesCommand.php | 29 ++
src/Command/IndexArticlesCommand.php | 64 ++++
src/Command/MarkAsIndexedCommand.php | 41 +++
.../NostrEventFromYamlDefinitionCommand.php | 86 +++++
src/Command/QualityCheckArticlesCommand.php | 69 ++++
.../CreditTransactionController.php | 24 ++
src/Controller/ArticleController.php | 93 ++++--
src/Controller/AuthorController.php | 14 +-
src/Controller/DefaultController.php | 62 +++-
src/Credits/Entity/CreditTransaction.php | 100 ++++++
src/Credits/Service/CreditsManager.php | 68 ++++
src/Credits/Util/RedisCreditStore.php | 90 ++++++
src/Factory/ArticleFactory.php | 2 +-
src/Security/UserDTOProvider.php | 19 +-
src/Service/NostrClient.php | 294 +++++++++++++-----
src/Twig/Components/GetCreditsComponent.php | 42 +++
src/Twig/Components/Header.php | 18 +-
src/Twig/Components/Molecules/Card.php | 11 +-
.../Components/Molecules/CategoryLink.php | 40 +++
.../Components/Molecules/UserFromNpub.php | 19 +-
src/Twig/Components/Organisms/Comments.php | 25 ++
.../Components/Organisms/FeaturedList.php | 52 ++++
src/Twig/Components/SearchComponent.php | 76 ++++-
src/Twig/Filters.php | 24 ++
src/Util/CommonMark/Converter.php | 4 +-
.../ImagesExtension/RawImageLinkExtension.php | 14 +
.../ImagesExtension/RawImageLinkParser.php | 35 +++
templates/admin/transactions.html.twig | 36 +++
.../components/GetCreditsComponent.html.twig | 6 +
templates/components/Header.html.twig | 18 +-
templates/components/Molecules/Card.html.twig | 49 ++-
.../Molecules/CategoryLink.html.twig | 4 +
.../Molecules/UserFromNpub.html.twig | 4 +-
.../components/Organisms/CardList.html.twig | 2 +-
.../components/Organisms/Comments.html.twig | 7 +
.../Organisms/FeaturedList.html.twig | 43 +++
.../components/SearchComponent.html.twig | 60 ++--
templates/home.html.twig | 6 +-
templates/pages/article.html.twig | 8 +-
templates/pages/author.html.twig | 62 ++--
templates/pages/search.html.twig | 5 -
templates/static/about.html.twig | 3 +-
templates/static/roadmap.html.twig | 14 +-
translations/messages.en.yaml | 3 +
57 files changed, 1871 insertions(+), 385 deletions(-)
create mode 100644 docker/cron/Dockerfile
create mode 100644 docker/cron/README.md
create mode 100644 docker/cron/crontab
create mode 100644 docker/cron/index_articles.sh
create mode 100644 migrations/Version20250509100039.php
create mode 100644 migrations/Version20250509100408.php
create mode 100644 src/Command/DatabaseCleanupCommand.php
create mode 100644 src/Command/ElevateUserCommand.php
create mode 100644 src/Command/GetArticlesCommand.php
create mode 100644 src/Command/IndexArticlesCommand.php
create mode 100644 src/Command/MarkAsIndexedCommand.php
create mode 100644 src/Command/NostrEventFromYamlDefinitionCommand.php
create mode 100644 src/Command/QualityCheckArticlesCommand.php
create mode 100644 src/Controller/Administration/CreditTransactionController.php
create mode 100644 src/Credits/Entity/CreditTransaction.php
create mode 100644 src/Credits/Service/CreditsManager.php
create mode 100644 src/Credits/Util/RedisCreditStore.php
create mode 100644 src/Twig/Components/GetCreditsComponent.php
create mode 100644 src/Twig/Components/Molecules/CategoryLink.php
create mode 100644 src/Twig/Components/Organisms/Comments.php
create mode 100644 src/Twig/Components/Organisms/FeaturedList.php
create mode 100644 src/Twig/Filters.php
create mode 100644 src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php
create mode 100644 src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php
create mode 100644 templates/admin/transactions.html.twig
create mode 100644 templates/components/GetCreditsComponent.html.twig
create mode 100644 templates/components/Molecules/CategoryLink.html.twig
create mode 100644 templates/components/Organisms/Comments.html.twig
create mode 100644 templates/components/Organisms/FeaturedList.html.twig
diff --git a/compose.yaml b/compose.yaml
index 897d403..688c8b3 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -5,7 +5,7 @@ services:
context: .
dockerfile: Dockerfile
environment:
- APP_ENCRYPTION_KEY: '%env(APP_ENCRYPTION_KEY)%'
+ APP_ENV: ${APP_ENV:-dev}
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
@@ -55,6 +55,14 @@ services:
# - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###
+ cron:
+ build:
+ context: ./docker/cron
+ volumes:
+ - .:/var/www/html
+ depends_on:
+ - php
+
volumes:
caddy_data:
caddy_config:
diff --git a/composer.lock b/composer.lock
index e72eece..5ce24ea 100644
--- a/composer.lock
+++ b/composer.lock
@@ -506,26 +506,29 @@
},
{
"name": "doctrine/deprecations",
- "version": "1.1.4",
+ "version": "1.1.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
- "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9"
+ "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9",
- "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=13"
+ },
"require-dev": {
- "doctrine/coding-standard": "^9 || ^12",
- "phpstan/phpstan": "1.4.10 || 2.0.3",
+ "doctrine/coding-standard": "^9 || ^12 || ^13",
+ "phpstan/phpstan": "1.4.10 || 2.1.11",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
@@ -545,9 +548,9 @@
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
- "source": "https://github.com/doctrine/deprecations/tree/1.1.4"
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.5"
},
- "time": "2024-12-07T21:18:45+00:00"
+ "time": "2025-04-07T20:06:18+00:00"
},
{
"name": "doctrine/doctrine-bundle",
@@ -1088,16 +1091,16 @@
},
{
"name": "doctrine/migrations",
- "version": "3.8.3",
+ "version": "3.9.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/migrations.git",
- "reference": "6af8dffde46a67f2a60906b6a28973e5a3670405"
+ "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/migrations/zipball/6af8dffde46a67f2a60906b6a28973e5a3670405",
- "reference": "6af8dffde46a67f2a60906b6a28973e5a3670405",
+ "url": "https://api.github.com/repos/doctrine/migrations/zipball/325b61e41d032f5f7d7e2d11cbefff656eadc9ab",
+ "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab",
"shasum": ""
},
"require": {
@@ -1117,7 +1120,7 @@
"require-dev": {
"doctrine/coding-standard": "^12",
"doctrine/orm": "^2.13 || ^3",
- "doctrine/persistence": "^2 || ^3",
+ "doctrine/persistence": "^2 || ^3 || ^4",
"doctrine/sql-formatter": "^1.0",
"ext-pdo_sqlite": "*",
"fig/log-test": "^1",
@@ -1171,7 +1174,7 @@
],
"support": {
"issues": "https://github.com/doctrine/migrations/issues",
- "source": "https://github.com/doctrine/migrations/tree/3.8.3"
+ "source": "https://github.com/doctrine/migrations/tree/3.9.0"
},
"funding": [
{
@@ -1187,7 +1190,7 @@
"type": "tidelift"
}
],
- "time": "2025-03-10T23:43:55+00:00"
+ "time": "2025-03-26T06:48:45+00:00"
},
{
"name": "doctrine/orm",
@@ -1558,16 +1561,16 @@
},
{
"name": "endroid/qr-code",
- "version": "6.0.6",
+ "version": "6.0.7",
"source": {
"type": "git",
"url": "https://github.com/endroid/qr-code.git",
- "reference": "11e6a94458dab8dd18736c11892130ec788b5028"
+ "reference": "de0e510509abf854e2218e4c77ed62321b27ea97"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/endroid/qr-code/zipball/11e6a94458dab8dd18736c11892130ec788b5028",
- "reference": "11e6a94458dab8dd18736c11892130ec788b5028",
+ "url": "https://api.github.com/repos/endroid/qr-code/zipball/de0e510509abf854e2218e4c77ed62321b27ea97",
+ "reference": "de0e510509abf854e2218e4c77ed62321b27ea97",
"shasum": ""
},
"require": {
@@ -1618,7 +1621,7 @@
],
"support": {
"issues": "https://github.com/endroid/qr-code/issues",
- "source": "https://github.com/endroid/qr-code/tree/6.0.6"
+ "source": "https://github.com/endroid/qr-code/tree/6.0.7"
},
"funding": [
{
@@ -1626,7 +1629,7 @@
"type": "github"
}
],
- "time": "2025-03-14T23:29:08+00:00"
+ "time": "2025-04-02T07:06:35+00:00"
},
{
"name": "endroid/qr-code-bundle",
@@ -2065,16 +2068,16 @@
},
{
"name": "league/commonmark",
- "version": "2.6.1",
+ "version": "2.6.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "d990688c91cedfb69753ffc2512727ec646df2ad"
+ "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad",
- "reference": "d990688c91cedfb69753ffc2512727ec646df2ad",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/06c3b0bf2540338094575612f4a1778d0d2d5e94",
+ "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94",
"shasum": ""
},
"require": {
@@ -2168,7 +2171,7 @@
"type": "tidelift"
}
],
- "time": "2024-12-29T14:10:59+00:00"
+ "time": "2025-04-18T21:09:27+00:00"
},
{
"name": "league/config",
@@ -2646,16 +2649,16 @@
},
{
"name": "nette/utils",
- "version": "v4.0.5",
+ "version": "v4.0.6",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
- "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96"
+ "reference": "ce708655043c7050eb050df361c5e313cf708309"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96",
- "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96",
+ "url": "https://api.github.com/repos/nette/utils/zipball/ce708655043c7050eb050df361c5e313cf708309",
+ "reference": "ce708655043c7050eb050df361c5e313cf708309",
"shasum": ""
},
"require": {
@@ -2726,9 +2729,9 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
- "source": "https://github.com/nette/utils/tree/v4.0.5"
+ "source": "https://github.com/nette/utils/tree/v4.0.6"
},
- "time": "2024-08-07T15:39:19+00:00"
+ "time": "2025-03-30T21:06:30+00:00"
},
{
"name": "nyholm/dsn",
@@ -3126,16 +3129,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
- "version": "5.6.1",
+ "version": "5.6.2",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
- "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8"
+ "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8",
- "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62",
+ "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62",
"shasum": ""
},
"require": {
@@ -3184,9 +3187,9 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
- "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1"
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.2"
},
- "time": "2024-12-07T09:39:29+00:00"
+ "time": "2025-04-13T19:20:35+00:00"
},
{
"name": "phpdocumentor/type-resolver",
@@ -3467,16 +3470,16 @@
},
{
"name": "phrity/websocket",
- "version": "3.3.0",
+ "version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/sirn-se/websocket-php.git",
- "reference": "73132b31f87b5f673ed492d9d4a4794cbbafe05c"
+ "reference": "716f96d7a36daf654a8fe9eb400ed9609c0882ca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sirn-se/websocket-php/zipball/73132b31f87b5f673ed492d9d4a4794cbbafe05c",
- "reference": "73132b31f87b5f673ed492d9d4a4794cbbafe05c",
+ "url": "https://api.github.com/repos/sirn-se/websocket-php/zipball/716f96d7a36daf654a8fe9eb400ed9609c0882ca",
+ "reference": "716f96d7a36daf654a8fe9eb400ed9609c0882ca",
"shasum": ""
},
"require": {
@@ -3523,9 +3526,9 @@
],
"support": {
"issues": "https://github.com/sirn-se/websocket-php/issues",
- "source": "https://github.com/sirn-se/websocket-php/tree/3.3.0"
+ "source": "https://github.com/sirn-se/websocket-php/tree/3.4.0"
},
- "time": "2025-03-14T14:27:33+00:00"
+ "time": "2025-04-07T09:38:39+00:00"
},
{
"name": "psr/cache",
@@ -4284,16 +4287,16 @@
},
{
"name": "swentel/nostr-php",
- "version": "1.6.0",
+ "version": "1.7.1",
"source": {
"type": "git",
"url": "https://github.com/nostrver-se/nostr-php.git",
- "reference": "541d9d074f942522425399efe0a77c80e44a1e21"
+ "reference": "44f17cad00dec8d3b5a35c3096a7ec83d8c683fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/541d9d074f942522425399efe0a77c80e44a1e21",
- "reference": "541d9d074f942522425399efe0a77c80e44a1e21",
+ "url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/44f17cad00dec8d3b5a35c3096a7ec83d8c683fa",
+ "reference": "44f17cad00dec8d3b5a35c3096a7ec83d8c683fa",
"shasum": ""
},
"require": {
@@ -4346,9 +4349,9 @@
"chat": "https://t.me/nostr_php",
"issue": "https://github.com/swentel/nostr-php/issues",
"issues": "https://github.com/nostrver-se/nostr-php/issues",
- "source": "https://github.com/nostrver-se/nostr-php/tree/1.6.0"
+ "source": "https://github.com/nostrver-se/nostr-php/tree/1.7.1"
},
- "time": "2025-03-17T14:43:56+00:00"
+ "time": "2025-04-08T14:52:48+00:00"
},
{
"name": "symfony/asset",
@@ -8068,16 +8071,16 @@
},
{
"name": "symfony/stimulus-bundle",
- "version": "v2.23.0",
+ "version": "v2.24.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/stimulus-bundle.git",
- "reference": "254f4e05cbaa349d4ae68b9b2e6a22995e0887f9"
+ "reference": "e09840304467cda3324cc116c7f4ee23c8ff227c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/254f4e05cbaa349d4ae68b9b2e6a22995e0887f9",
- "reference": "254f4e05cbaa349d4ae68b9b2e6a22995e0887f9",
+ "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/e09840304467cda3324cc116c7f4ee23c8ff227c",
+ "reference": "e09840304467cda3324cc116c7f4ee23c8ff227c",
"shasum": ""
},
"require": {
@@ -8117,7 +8120,7 @@
"symfony-ux"
],
"support": {
- "source": "https://github.com/symfony/stimulus-bundle/tree/v2.23.0"
+ "source": "https://github.com/symfony/stimulus-bundle/tree/v2.24.0"
},
"funding": [
{
@@ -8133,7 +8136,7 @@
"type": "tidelift"
}
],
- "time": "2025-01-16T21:55:09+00:00"
+ "time": "2025-03-09T21:10:04+00:00"
},
{
"name": "symfony/stopwatch",
@@ -8733,16 +8736,16 @@
},
{
"name": "symfony/ux-icons",
- "version": "v2.23.0",
+ "version": "v2.24.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-icons.git",
- "reference": "6aa0b14dededcd65c515c445aedfda318fa61f9e"
+ "reference": "39f689b41081f7788ee9c4a188817599d546bfb2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/ux-icons/zipball/6aa0b14dededcd65c515c445aedfda318fa61f9e",
- "reference": "6aa0b14dededcd65c515c445aedfda318fa61f9e",
+ "url": "https://api.github.com/repos/symfony/ux-icons/zipball/39f689b41081f7788ee9c4a188817599d546bfb2",
+ "reference": "39f689b41081f7788ee9c4a188817599d546bfb2",
"shasum": ""
},
"require": {
@@ -8802,7 +8805,7 @@
"twig"
],
"support": {
- "source": "https://github.com/symfony/ux-icons/tree/v2.23.0"
+ "source": "https://github.com/symfony/ux-icons/tree/v2.24.0"
},
"funding": [
{
@@ -8818,26 +8821,27 @@
"type": "tidelift"
}
],
- "time": "2024-12-26T07:58:05+00:00"
+ "time": "2025-04-04T17:32:18+00:00"
},
{
"name": "symfony/ux-live-component",
- "version": "v2.23.0",
+ "version": "v2.24.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-live-component.git",
- "reference": "840542868a8473b49036ec1ed0c5238d14b075a8"
+ "reference": "ee1a8e5d01f5b3f2f8e6856941fa8c944677e41c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/840542868a8473b49036ec1ed0c5238d14b075a8",
- "reference": "840542868a8473b49036ec1ed0c5238d14b075a8",
+ "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/ee1a8e5d01f5b3f2f8e6856941fa8c944677e41c",
+ "reference": "ee1a8e5d01f5b3f2f8e6856941fa8c944677e41c",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/property-access": "^5.4.5|^6.0|^7.0",
+ "symfony/property-info": "^5.4|^6.0|^7.0",
"symfony/stimulus-bundle": "^2.9",
"symfony/ux-twig-component": "^2.8",
"twig/twig": "^3.8.0"
@@ -8858,7 +8862,6 @@
"symfony/framework-bundle": "^5.4|^6.0|^7.0",
"symfony/options-resolver": "^5.4|^6.0|^7.0",
"symfony/phpunit-bridge": "^6.1|^7.0",
- "symfony/property-info": "^5.4|^6.0|^7.0",
"symfony/security-bundle": "^5.4|^6.0|^7.0",
"symfony/serializer": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
@@ -8896,7 +8899,7 @@
"twig"
],
"support": {
- "source": "https://github.com/symfony/ux-live-component/tree/v2.23.0"
+ "source": "https://github.com/symfony/ux-live-component/tree/v2.24.0"
},
"funding": [
{
@@ -8912,20 +8915,20 @@
"type": "tidelift"
}
],
- "time": "2025-02-07T23:57:34+00:00"
+ "time": "2025-03-12T08:41:47+00:00"
},
{
"name": "symfony/ux-twig-component",
- "version": "v2.23.0",
+ "version": "v2.24.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-twig-component.git",
- "reference": "f29033b95e93aea2d498dc40eac185ed14b07800"
+ "reference": "48a46e4c6215d41cc97ba8dff0cff21ea9b255a8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/f29033b95e93aea2d498dc40eac185ed14b07800",
- "reference": "f29033b95e93aea2d498dc40eac185ed14b07800",
+ "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/48a46e4c6215d41cc97ba8dff0cff21ea9b255a8",
+ "reference": "48a46e4c6215d41cc97ba8dff0cff21ea9b255a8",
"shasum": ""
},
"require": {
@@ -8979,7 +8982,7 @@
"twig"
],
"support": {
- "source": "https://github.com/symfony/ux-twig-component/tree/v2.23.0"
+ "source": "https://github.com/symfony/ux-twig-component/tree/v2.24.0"
},
"funding": [
{
@@ -8995,7 +8998,7 @@
"type": "tidelift"
}
],
- "time": "2025-01-25T02:19:26+00:00"
+ "time": "2025-03-21T20:14:36+00:00"
},
{
"name": "symfony/var-dumper",
@@ -9612,16 +9615,16 @@
"packages-dev": [
{
"name": "myclabs/deep-copy",
- "version": "1.13.0",
+ "version": "1.13.1",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "024473a478be9df5fdaca2c793f2232fe788e414"
+ "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414",
- "reference": "024473a478be9df5fdaca2c793f2232fe788e414",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c",
+ "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c",
"shasum": ""
},
"require": {
@@ -9660,7 +9663,7 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0"
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1"
},
"funding": [
{
@@ -9668,7 +9671,7 @@
"type": "tidelift"
}
],
- "time": "2025-02-12T12:17:51+00:00"
+ "time": "2025-04-29T12:36:36+00:00"
},
{
"name": "nikic/php-parser",
@@ -11433,21 +11436,21 @@
},
{
"name": "symfony/maker-bundle",
- "version": "v1.62.1",
+ "version": "v1.63.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/maker-bundle.git",
- "reference": "468ff2708200c95ebc0d85d3174b6c6711b8a590"
+ "reference": "69478ab39bc303abfbe3293006a78b09a8512425"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/468ff2708200c95ebc0d85d3174b6c6711b8a590",
- "reference": "468ff2708200c95ebc0d85d3174b6c6711b8a590",
+ "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/69478ab39bc303abfbe3293006a78b09a8512425",
+ "reference": "69478ab39bc303abfbe3293006a78b09a8512425",
"shasum": ""
},
"require": {
"doctrine/inflector": "^2.0",
- "nikic/php-parser": "^4.18|^5.0",
+ "nikic/php-parser": "^5.0",
"php": ">=8.1",
"symfony/config": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0",
@@ -11505,7 +11508,7 @@
],
"support": {
"issues": "https://github.com/symfony/maker-bundle/issues",
- "source": "https://github.com/symfony/maker-bundle/tree/v1.62.1"
+ "source": "https://github.com/symfony/maker-bundle/tree/v1.63.0"
},
"funding": [
{
@@ -11521,7 +11524,7 @@
"type": "tidelift"
}
],
- "time": "2025-01-15T00:21:40+00:00"
+ "time": "2025-04-26T01:41:37+00:00"
},
{
"name": "symfony/phpunit-bridge",
diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml
index 86c2e75..e3805ad 100644
--- a/config/packages/cache.yaml
+++ b/config/packages/cache.yaml
@@ -15,3 +15,4 @@ framework:
pools:
#my.dedicated.cache: null
subscriptions.cache: null
+ credits.cache: null
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
index 46da46a..9d1f84c 100644
--- a/config/packages/doctrine.yaml
+++ b/config/packages/doctrine.yaml
@@ -24,6 +24,12 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
+ Credits:
+ type: attribute
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Credits/Entity'
+ prefix: 'App\Credits\Entity'
+ alias: Credits
controller_resolver:
auto_mapping: false
diff --git a/config/services.yaml b/config/services.yaml
index ec4a1c1..2cff107 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -66,3 +66,7 @@ services:
App\EventListener\PopulateListener:
tags:
- { name: kernel.event_listener, event: 'FOS\ElasticaBundle\Event\PostIndexPopulateEvent', method: 'postIndexPopulate' }
+
+ App\Command\IndexArticlesCommand:
+ arguments:
+ $itemPersister: '@fos_elastica.object_persister.articles'
diff --git a/docker/cron/Dockerfile b/docker/cron/Dockerfile
new file mode 100644
index 0000000..59c3a5e
--- /dev/null
+++ b/docker/cron/Dockerfile
@@ -0,0 +1,36 @@
+FROM php:8.2-cli
+
+# Install cron and Redis PHP extension dependencies
+RUN apt-get update && apt-get install -y \
+ cron \
+ libzip-dev \
+ libicu-dev \
+ libpq-dev \
+ libonig-dev
+
+# Install Redis PHP extension
+RUN pecl install redis \
+ && docker-php-ext-enable redis
+
+RUN docker-php-ext-install pdo pdo_pgsql
+
+
+# Set working directory
+WORKDIR /var/www/html
+
+# Install Symfony CLI tools (optional)
+# RUN curl -sS https://get.symfony.com/cli/installer | bash
+
+# Copy cron and script
+COPY crontab /etc/cron.d/app-cron
+COPY index_articles.sh /index_articles.sh
+
+# Set permissions
+RUN chmod 0644 /etc/cron.d/app-cron && \
+ chmod +x /index_articles.sh
+
+# Apply cron job
+RUN crontab /etc/cron.d/app-cron
+
+# Run cron in the foreground
+CMD ["cron", "-f"]
diff --git a/docker/cron/README.md b/docker/cron/README.md
new file mode 100644
index 0000000..ee65164
--- /dev/null
+++ b/docker/cron/README.md
@@ -0,0 +1,94 @@
+
+# 🕒 Cron Job Container
+
+This folder contains the Docker configuration to run scheduled Symfony commands via cron inside a separate container.
+
+- Run Symfony console commands periodically using a cron schedule (e.g. every 6 hours)
+- Decouple scheduled jobs from the main PHP/FPM container
+- Easily manage and test cron execution in a Dockerized Symfony project
+
+---
+
+## Build & Run
+
+1. **Build the cron image**
+ From the project root:
+ ```bash
+ docker-compose build cron
+ ```
+
+2. **Start the cron container**
+ ```bash
+ docker-compose up -d cron
+ ```
+
+---
+
+## Cron Schedule
+
+The default cron schedule is set to run **every 6 hours**:
+
+```cron
+0 */6 * * * root /run_commands.sh >> /var/log/cron.log 2>&1
+```
+
+To customize the schedule, edit the `crontab` file and rebuild the container.
+
+---
+
+## Testing & Debugging
+
+### Manually test the command runner
+
+You can run the script manually to check behavior without waiting for the cron trigger:
+
+```bash
+docker-compose exec cron /run_commands.sh
+```
+
+### Check the cron output log
+
+```bash
+docker-compose exec cron tail -f /var/log/cron.log
+```
+
+### Shell into the cron container
+
+```bash
+docker-compose exec cron bash
+```
+
+Once inside, you can:
+- Check crontab entries: `crontab -l`
+- Manually trigger cron: `cron` or `cron -f` (in another session)
+
+---
+
+## Customization
+
+- **Add/Remove Symfony Commands:**
+ Edit `run_commands.sh` to include the commands you want to run.
+
+- **Change Schedule:**
+ Edit `crontab` using standard cron syntax.
+
+- **Logging:**
+ Logs are sent to `/var/log/cron.log` inside the container.
+
+---
+
+## Rebuilding After Changes
+
+If you modify the `crontab` or `run_commands.sh`, make sure to rebuild:
+
+```bash
+docker-compose build cron
+docker-compose up -d cron
+```
+
+---
+
+## Notes
+
+- Symfony project source is mounted at `/var/www/html` via volume.
+- Make sure your commands do **not rely on services** (like `php-fpm`) that are not running in this container.
diff --git a/docker/cron/crontab b/docker/cron/crontab
new file mode 100644
index 0000000..ad396cb
--- /dev/null
+++ b/docker/cron/crontab
@@ -0,0 +1 @@
+0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1
diff --git a/docker/cron/index_articles.sh b/docker/cron/index_articles.sh
new file mode 100644
index 0000000..afbf497
--- /dev/null
+++ b/docker/cron/index_articles.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+set -e
+
+# Run Symfony commands sequentially
+/usr/local/bin/php /var/www/html/bin/console articles:get
+/usr/local/bin/php /var/www/html/bin/console articles:qa
+/usr/local/bin/php /var/www/html/bin/console articles:index
+/usr/local/bin/php /var/www/html/bin/console articles:indexed
diff --git a/migrations/Version20250509100039.php b/migrations/Version20250509100039.php
new file mode 100644
index 0000000..c79bf4e
--- /dev/null
+++ b/migrations/Version20250509100039.php
@@ -0,0 +1,38 @@
+addSql(<<<'SQL'
+ DROP TABLE sessions
+ SQL);
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql(<<<'SQL'
+ CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL, sess_data BYTEA NOT NULL, sess_lifetime INT NOT NULL, sess_time INT NOT NULL, PRIMARY KEY(sess_id))
+ SQL);
+ $this->addSql(<<<'SQL'
+ CREATE INDEX sess_lifetime_idx ON sessions (sess_lifetime)
+ SQL);
+ }
+}
diff --git a/migrations/Version20250509100408.php b/migrations/Version20250509100408.php
new file mode 100644
index 0000000..2eff9e0
--- /dev/null
+++ b/migrations/Version20250509100408.php
@@ -0,0 +1,35 @@
+addSql(<<<'SQL'
+ CREATE TABLE credit_transaction (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(64) NOT NULL, amount INT NOT NULL, type VARCHAR(16) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, reason VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))
+ SQL);
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql(<<<'SQL'
+ DROP TABLE credit_transaction
+ SQL);
+ }
+}
diff --git a/src/Command/DatabaseCleanupCommand.php b/src/Command/DatabaseCleanupCommand.php
new file mode 100644
index 0000000..13eadd9
--- /dev/null
+++ b/src/Command/DatabaseCleanupCommand.php
@@ -0,0 +1,45 @@
+entityManager->getRepository(Article::class);
+ $items = $repository->findBy(['indexStatus' => IndexStatusEnum::DO_NOT_INDEX]);
+
+ if (empty($items)) {
+ $output->writeln('No items found. ');
+ return Command::SUCCESS;
+ }
+
+ foreach ($items as $item) {
+ $this->entityManager->remove($item);
+ }
+
+ $this->entityManager->flush();
+
+ $output->writeln('Deleted ' . count($items) . ' items. ');
+
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/ElevateUserCommand.php b/src/Command/ElevateUserCommand.php
new file mode 100644
index 0000000..575dc38
--- /dev/null
+++ b/src/Command/ElevateUserCommand.php
@@ -0,0 +1,52 @@
+addArgument('arg1', InputArgument::REQUIRED, 'User npub')
+ ->addArgument('arg2', InputArgument::REQUIRED, 'Role to set');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $npub = $input->getArgument('arg1');
+ $role = $input->getArgument('arg2');
+ if (!str_starts_with($role, 'ROLE_')) {
+ return Command::INVALID;
+ }
+
+ $user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
+ if (!$user) {
+ return Command::FAILURE;
+ }
+
+ $user->addRole($role);
+ $this->entityManager->persist($user);
+ $this->entityManager->flush();
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/GetArticlesCommand.php b/src/Command/GetArticlesCommand.php
new file mode 100644
index 0000000..908832f
--- /dev/null
+++ b/src/Command/GetArticlesCommand.php
@@ -0,0 +1,29 @@
+nostrClient->getLongFormContent();
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/IndexArticlesCommand.php b/src/Command/IndexArticlesCommand.php
new file mode 100644
index 0000000..109d8bd
--- /dev/null
+++ b/src/Command/IndexArticlesCommand.php
@@ -0,0 +1,64 @@
+entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]);
+
+ $batchCount = 0;
+ $processedCount = 0;
+
+ foreach ($articles as $item) {
+ $batchCount++;
+
+ // Collect batch of entities for indexing
+ $batchItems[] = $item;
+
+ // Process batch when limit is reached
+ if ($batchCount >= self::BATCH_SIZE) {
+ $this->flushAndPersistBatch($batchItems);
+ $processedCount += $batchCount;
+ $batchCount = 0;
+ $batchItems = [];
+ }
+ }
+
+ // Process any remaining items
+ if (!empty($batchItems)) {
+ $this->flushAndPersistBatch($batchItems);
+ $processedCount += count($batchItems);
+ }
+
+ $output->writeln("$processedCount items indexed in Elasticsearch.");
+ return Command::SUCCESS;
+ }
+
+ private function flushAndPersistBatch(array $items): void
+ {
+ // Persist batch to Elasticsearch
+ $this->itemPersister->replaceMany($items);
+ }
+}
diff --git a/src/Command/MarkAsIndexedCommand.php b/src/Command/MarkAsIndexedCommand.php
new file mode 100644
index 0000000..0bf3b43
--- /dev/null
+++ b/src/Command/MarkAsIndexedCommand.php
@@ -0,0 +1,41 @@
+entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]);
+ $count = 0;
+ foreach ($articles as $article) {
+ if ($article instanceof Article) {
+ $count += 1;
+ $article->setIndexStatus(IndexStatusEnum::INDEXED);
+ $this->entityManager->persist($article);
+ }
+ }
+
+ $this->entityManager->flush();
+
+ $output->writeln($count . ' articles marked as indexed successfully.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/NostrEventFromYamlDefinitionCommand.php b/src/Command/NostrEventFromYamlDefinitionCommand.php
new file mode 100644
index 0000000..cf5e79a
--- /dev/null
+++ b/src/Command/NostrEventFromYamlDefinitionCommand.php
@@ -0,0 +1,86 @@
+addArgument('folder', InputArgument::REQUIRED, 'The folder location to start scanning from.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $folder = $input->getArgument('folder');
+
+ // Use Symfony Finder to locate YAML files recursively
+ $finder = new Finder();
+ $finder->files()
+ ->in($folder)
+ ->name('*.yaml')
+ ->name('*.yml');
+
+ if (!$finder->hasResults()) {
+ $output->writeln('No YAML files found in the specified directory. ');
+ return Command::SUCCESS;
+ }
+
+ foreach ($finder as $file) {
+ $filePath = $file->getRealPath();
+ $output->writeln("Processing file: $filePath ");
+ $yamlContent = Yaml::parseFile($filePath); // This parses the YAML file
+
+ try {
+ // Deserialize YAML content into an Event object
+ $event = new Event();
+ $event->setKind(30040);
+ $event->setPublicKey('e00983324f38e8522ffc01d5c064727e43fe4c43d86a5c2a0e73290674e496f8');
+ $tags = $yamlContent['tags'];
+ $event->setTags($tags);
+
+ $signer = new Sign();
+ $signer->signEvent($event, NostrEventFromYamlDefinitionCommand::private_key);
+
+ // Save to cache
+ $slug = array_filter($tags, function ($tag) {
+ return ($tag[0] === 'd');
+ });
+ // Generate a Redis key
+ $cacheKey = 'magazine-' . $slug[0][1];
+ $cacheItem = $this->redisCache->getItem($cacheKey);
+ $cacheItem->set($event);
+ $this->redisCache->save($cacheItem);
+
+ $output->writeln("Saved index. ");
+ } catch (\Exception $e) {
+ $output->writeln("Error deserializing YAML in file: $filePath. Message: {$e->getMessage()} ");
+ continue;
+ }
+ }
+
+ $output->writeln('Conversion complete. ');
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/QualityCheckArticlesCommand.php b/src/Command/QualityCheckArticlesCommand.php
new file mode 100644
index 0000000..dfdc123
--- /dev/null
+++ b/src/Command/QualityCheckArticlesCommand.php
@@ -0,0 +1,69 @@
+entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::NOT_INDEXED]);
+ $count = 0;
+ foreach ($articles as $article) {
+ if ($this->meetsCriteria($article)) {
+ $count += 1;
+ $article->setIndexStatus(IndexStatusEnum::TO_BE_INDEXED);
+ } else {
+ $article->setIndexStatus(IndexStatusEnum::DO_NOT_INDEX);
+ }
+ $this->entityManager->persist($article);
+ }
+
+ $this->entityManager->flush();
+
+ $output->writeln($count . ' articles marked for indexing successfully.');
+
+ return Command::SUCCESS;
+ }
+
+ private function meetsCriteria(Article $article): bool
+ {
+ $content = $article->getContent();
+
+ // No empty title
+ if (empty($article->getTitle()) || strtolower($article->getTitle()) === 'test') {
+ return false;
+ }
+
+ // Do not index stacker news reposts
+ if (str_contains($content, 'originally posted at https://stacker.news')) {
+ return false;
+ }
+
+ // Slug must not contain '/' and should not be empty
+ if (empty($article->getSlug()) || str_contains($article->getSlug(), '/')) {
+ return false;
+ }
+
+ // Only index articles with more than 12 words
+ return str_word_count($article->getContent()) > 12;
+ }
+}
diff --git a/src/Controller/Administration/CreditTransactionController.php b/src/Controller/Administration/CreditTransactionController.php
new file mode 100644
index 0000000..64e0f43
--- /dev/null
+++ b/src/Controller/Administration/CreditTransactionController.php
@@ -0,0 +1,24 @@
+getRepository(CreditTransaction::class)->findBy([], ['createdAt' => 'DESC']);
+
+ return $this->render('admin/transactions.html.twig', [
+ 'transactions' => $transactions,
+ ]);
+ }
+}
diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php
index 0463920..bf6cdc5 100644
--- a/src/Controller/ArticleController.php
+++ b/src/Controller/ArticleController.php
@@ -12,16 +12,17 @@ use Doctrine\ORM\EntityManagerInterface;
use League\CommonMark\Exception\CommonMarkException;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
+use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Workflow\WorkflowInterface;
class ArticleController extends AbstractController
{
/**
- * @throws InvalidArgumentException|CommonMarkException
* @throws \Exception
*/
#[Route('/article/{naddr}', name: 'article-naddr')]
@@ -64,7 +65,12 @@ class ArticleController extends AbstractController
throw new \Exception('Not a long form article');
}
- $nostrClient->getLongFormFromNaddr($slug, $relays, $author, $kind);
+ if (empty($relays ?? [])) {
+ // get author npub relays from their config
+ $relays = $nostrClient->getNpubRelays($author);
+ }
+
+ $nostrClient->getLongFormFromNaddr($slug, $relays ?? null, $author, $kind);
if ($slug) {
return $this->redirectToRoute('article-slug', ['slug' => $slug]);
@@ -86,6 +92,10 @@ class ArticleController extends AbstractController
$articles = $repository->findBy(['slug' => $slug]);
$revisions = count($articles);
+ if ($revisions === 0) {
+ throw $this->createNotFoundException('The article could not be found');
+ }
+
if ($revisions > 1) {
// sort articles by created at date
usort($articles, function ($a, $b) {
@@ -97,16 +107,12 @@ class ArticleController extends AbstractController
$article = $articles[0];
}
- if (!$article) {
- throw $this->createNotFoundException('The article does not exist');
- }
-
$cacheKey = 'article_' . $article->getId();
$cacheItem = $articlesCache->getItem($cacheKey);
- if (!$cacheItem->isHit()) {
+// if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent()));
$articlesCache->save($cacheItem);
- }
+ //}
// // suggestions
// $suggestions = $repository->findBy(['pubkey' => $article->getPubkey()], ['createdAt' => 'DESC'], 3);
@@ -120,18 +126,23 @@ class ArticleController extends AbstractController
// return $b->getCreatedAt() <=> $a->getCreatedAt();
// });
- $meta = $nostrClient->getNpubMetadata($article->getPubkey());
- if ($meta?->content) {
- $author = (array) json_decode($meta->content);
- } else {
- $author = [
- 'name' => ''
- ];
+ try {
+ $meta = $nostrClient->getNpubMetadata($article->getPubkey());
+ if ($meta?->content) {
+ $author = (array) json_decode($meta->content);
+ } else {
+ $author = [
+ 'name' => ''
+ ];
+ }
+ } catch (\Exception $e) {
+ // Whatever?
}
+
return $this->render('Pages/article.html.twig', [
'article' => $article,
- 'author' => $author,
+ 'author' => $author ?? null,
'content' => $cacheItem->get(),
//'suggestions' => $suggestions
]);
@@ -140,10 +151,13 @@ class ArticleController extends AbstractController
/**
* Create new article
+ * @throws InvalidArgumentException
+ * @throws \Exception
*/
#[Route('/article-editor/create', name: 'editor-create')]
#[Route('/article-editor/edit/{id}', name: 'editor-edit')]
- public function newArticle(Request $request, EntityManagerInterface $entityManager, WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response
+ public function newArticle(Request $request, EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache,
+ WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response
{
if (!$article) {
$article = new Article();
@@ -160,24 +174,33 @@ class ArticleController extends AbstractController
// Step 3: Check if the form is submitted and valid
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser();
- $currentPubkey = $user->getUserIdentifier();
+ $key = new Key();
+ $currentPubkey = $key->convertToHex($user->getUserIdentifier());
+
if ($article->getPubkey() === null) {
$article->setPubkey($currentPubkey);
}
// Check which button was clicked
- if ($form->get('actions')->get('submit')->isClicked()) {
+ if ($form->getClickedButton() === $form->get('actions')->get('submit')) {
// Save button was clicked, handle the "Publish" action
$this->addFlash('success', 'Product published!');
- } elseif ($form->get('actions')->get('draft')->isClicked()) {
+ } elseif ($form->getClickedButton() === $form->get('actions')->get('draft')) {
// Save and Publish button was clicked, handle the "Draft" action
$this->addFlash('success', 'Product saved as draft!');
- } elseif ($form->get('actions')->get('preview')->isClicked()) {
+ } elseif ($form->getClickedButton() === $form->get('actions')->get('preview')) {
// Preview button was clicked, handle the "Preview" action
+ // construct slug from title and save to tags
+ $slugger = new AsciiSlugger();
+ $slug = $slugger->slug($article->getTitle())->lower();
$article->setSig(''); // clear the sig
- $entityManager->persist($article);
- $entityManager->flush();
- return $this->redirectToRoute('article-preview', ['id' => $article->getId()]);
+ $article->setSlug($slug);
+ $cacheKey = 'article_' . $currentPubkey . '_' . $article->getSlug();
+ $cacheItem = $articlesCache->getItem($cacheKey);
+ $cacheItem->set($article);
+ $articlesCache->save($cacheItem);
+
+ return $this->redirectToRoute('article-preview', ['d' => $article->getSlug()]);
}
}
@@ -190,16 +213,28 @@ class ArticleController extends AbstractController
/**
* Preview article
+ * @throws InvalidArgumentException
+ * @throws CommonMarkException
+ * @throws \Exception
*/
- #[Route('/article-preview/{id}', name: 'article-preview')]
- public function preview($id, EntityManagerInterface $entityManager): Response
+ #[Route('/article-preview/{d}', name: 'article-preview')]
+ public function preview($d, Converter $converter,
+ CacheItemPoolInterface $articlesCache): Response
{
- $repository = $entityManager->getRepository(Article::class);
- $article = $repository->findOneBy(['id' => $id]);
+ $user = $this->getUser();
+ $key = new Key();
+ $currentPubkey = $key->convertToHex($user->getUserIdentifier());
+
+ $cacheKey = 'article_' . $currentPubkey . '_' . $d;
+ $cacheItem = $articlesCache->getItem($cacheKey);
+ $article = $cacheItem->get();
+
+ $content = $converter->convertToHtml($article->getContent());
return $this->render('pages/article.html.twig', [
'article' => $article,
- 'author' => $this->getUser(),
+ 'content' => $content,
+ 'author' => $user->getMetadata(),
]);
}
diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php
index 2bd1dd0..01ffff7 100644
--- a/src/Controller/AuthorController.php
+++ b/src/Controller/AuthorController.php
@@ -25,15 +25,13 @@ class AuthorController extends AbstractController
public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response
{
$keys = new Key();
+ $pubkey = $keys->convertToHex($npub);
- $meta = $client->getNpubMetadata($npub);
- $author = (array) json_decode($meta->content ?? '{}');
-
- // $client->getNpubLongForm($npub);
+ $meta = $client->getNpubMetadata($pubkey);
- $pubkey = $keys->convertToHex($npub);
+ $author = json_decode($meta->content ?? '{}');
- $list = $entityManager->getRepository(Article::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::LONGFORM], ['createdAt' => 'DESC']);
+ $list = $client->getLongFormContentForPubkey($pubkey);
// deduplicate by slugs
$articles = [];
@@ -43,7 +41,7 @@ class AuthorController extends AbstractController
}
}
- $indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
+ // $indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// $nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $pubkey]);
@@ -55,7 +53,7 @@ class AuthorController extends AbstractController
'articles' => $articles,
'nzine' => null,
'nzines' => null,
- 'idx' => $indices
+ 'idx' => null
]);
}
diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php
index effd91d..7ad63ee 100644
--- a/src/Controller/DefaultController.php
+++ b/src/Controller/DefaultController.php
@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Controller;
-use App\Entity\Article;
-use App\Enum\IndexStatusEnum;
-use Doctrine\ORM\EntityManagerInterface;
+use Elastica\Query\MatchQuery;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
+use swentel\nostr\Event\Event;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -19,30 +18,59 @@ class DefaultController extends AbstractController
{
public function __construct(
private readonly FinderInterface $esFinder,
- private readonly EntityManagerInterface $entityManager)
+ private readonly CacheInterface $redisCache)
{
}
/**
* @throws \Exception
+ * @throws InvalidArgumentException
*/
- #[Route('/', name: 'default')]
- public function index(): Response
+ #[Route('/', name: 'home')]
+ public function index(FinderInterface $finder): Response
{
- $list = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::INDEXED], ['createdAt' => 'DESC'], 10);
+ // get newsroom index, loop over categories, pick top three from each and display in sections
+ $mag = $this->redisCache->get('magazine-newsroom-magazine-by-newsroom', function (){
+ return null;
+ });
+ $tags = $mag->getTags();
- // deduplicate by slugs
- $deduplicated = [];
- foreach ($list as $item) {
- if (!key_exists((string) $item->getSlug(), $deduplicated)) {
- $deduplicated[(string) $item->getSlug()] = $item;
+ $cats = array_filter($tags, function($tag) {
+ return ($tag[0] === 'a');
+ });
+
+ return $this->render('home.html.twig', [
+ 'indices' => $cats
+ ]);
+ }
+
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ #[Route('/cat/{slug}', name: 'magazine-category')]
+ public function magCategory($slug, CacheInterface $redisCache, FinderInterface $finder): Response
+ {
+ $catIndex = $redisCache->get('magazine-' . $slug, function (){
+ throw new \Exception('Not found');
+ });
+
+ $articles = [];
+ foreach ($catIndex->getTags() as $tag) {
+ if ($tag[0] === 'a') {
+ $parts = explode(':', $tag[1]);
+ if (count($parts) === 3) {
+ $fieldQuery = new MatchQuery();
+ $fieldQuery->setFieldQuery('slug', $parts[2]);
+ $res = $finder->find($fieldQuery);
+ $articles[] = $res[0];
+ }
}
}
- return $this->render('home.html.twig', [
- 'list' => array_values(array_filter($deduplicated, function($item) {
- return !empty($item->getImage());
- }))
+
+ return $this->render('pages/category.html.twig', [
+ 'list' => array_slice($articles, 0, 9)
]);
}
diff --git a/src/Credits/Entity/CreditTransaction.php b/src/Credits/Entity/CreditTransaction.php
new file mode 100644
index 0000000..d2db15d
--- /dev/null
+++ b/src/Credits/Entity/CreditTransaction.php
@@ -0,0 +1,100 @@
+npub = $npub;
+ $this->amount = $amount;
+ $this->type = $type;
+ $this->createdAt = new \DateTime();
+ $this->reason = $reason;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setId(int $id): void
+ {
+ $this->id = $id;
+ }
+
+ public function getNpub(): string
+ {
+ return $this->npub;
+ }
+
+ public function setNpub(string $npub): void
+ {
+ $this->npub = $npub;
+ }
+
+ public function getAmount(): int
+ {
+ return $this->amount;
+ }
+
+ public function setAmount(int $amount): void
+ {
+ $this->amount = $amount;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function setType(string $type): void
+ {
+ $this->type = $type;
+ }
+
+ public function getCreatedAt(): \DateTime
+ {
+ return $this->createdAt;
+ }
+
+ public function setCreatedAt(\DateTime $createdAt): void
+ {
+ $this->createdAt = $createdAt;
+ }
+
+ public function getReason(): ?string
+ {
+ return $this->reason;
+ }
+
+ public function setReason(?string $reason): void
+ {
+ $this->reason = $reason;
+ }
+
+}
+
diff --git a/src/Credits/Service/CreditsManager.php b/src/Credits/Service/CreditsManager.php
new file mode 100644
index 0000000..439be70
--- /dev/null
+++ b/src/Credits/Service/CreditsManager.php
@@ -0,0 +1,68 @@
+redisStore->getBalance($npub);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function resetBalance(string $npub): int
+ {
+ return $this->redisStore->resetBalance($npub);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function addCredits(string $npub, int $amount, ?string $reason = null): void
+ {
+ $this->redisStore->addCredits($npub, $amount);
+
+ $tx = new CreditTransaction($npub, $amount, 'credit', $reason);
+ $this->em->persist($tx);
+ $this->em->flush();
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function canAfford(string $npub, int $cost): bool
+ {
+ return $this->getBalance($npub) >= $cost;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function spendCredits(string $npub, int $cost, ?string $reason = null): void
+ {
+ if (!$this->canAfford($npub, $cost)) {
+ throw new \RuntimeException("Insufficient credits for $npub");
+ }
+
+ $this->redisStore->spendCredits($npub, $cost);
+
+ $tx = new CreditTransaction($npub, $cost, 'debit', $reason);
+ $this->em->persist($tx);
+ $this->em->flush();
+ }
+}
diff --git a/src/Credits/Util/RedisCreditStore.php b/src/Credits/Util/RedisCreditStore.php
new file mode 100644
index 0000000..b9e8d83
--- /dev/null
+++ b/src/Credits/Util/RedisCreditStore.php
@@ -0,0 +1,90 @@
+creditsCache->delete($this->key($npub));
+
+ // Fetch all transactions for the given npub
+ $transactions = $this->em->getRepository(CreditTransaction::class)
+ ->findBy(['npub' => $npub]);
+
+ // Initialize the balance
+ $balance = 0;
+
+ // Calculate the final balance based on the transactions
+ foreach ($transactions as $tx) {
+ if ($tx->getType() === 'credit') {
+ $balance += $tx->getAmount();
+ } elseif ($tx->getType() === 'debit') {
+ $balance -= $tx->getAmount();
+ }
+ }
+
+ // Write the calculated balance into the Redis cache
+ $item = $this->creditsCache->getItem($this->key($npub));
+ $item->set($balance);
+ $this->creditsCache->save($item);
+
+ return $balance;
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function getBalance(string $npub): int
+ {
+ // Use cache pool to fetch the credit balance
+ return $this->creditsCache->get($this->key($npub), function () use ($npub) {
+ return $this->resetBalance($npub);
+ });
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function addCredits(string $npub, int $amount): void
+ {
+ $currentBalance = $this->getBalance($npub);
+ $item = $this->creditsCache->getItem($this->key($npub));
+ $balance = $currentBalance + $amount;
+ $item->set($balance);
+ $this->creditsCache->save($item);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function spendCredits(string $npub, int $amount): void
+ {
+ $currentBalance = $this->getBalance($npub);
+ $item = $this->creditsCache->getItem($this->key($npub));
+ if ($currentBalance < $amount) {
+ throw new \RuntimeException('Insufficient credits');
+ }
+ $balance = $currentBalance - $amount;
+ $item->set($balance);
+ $this->creditsCache->save($item);
+ }
+}
diff --git a/src/Factory/ArticleFactory.php b/src/Factory/ArticleFactory.php
index 060d039..5c69126 100644
--- a/src/Factory/ArticleFactory.php
+++ b/src/Factory/ArticleFactory.php
@@ -21,7 +21,6 @@ class ArticleFactory
$entity->setRaw($source);
$entity->setEventId($source->id);
$entity->setCreatedAt(\DateTimeImmutable::createFromFormat('U', (string)$source->created_at));
- // TODO escape content before saving
$entity->setContent($source->content);
$entity->setKind(KindsEnum::from($source->kind));
$entity->setPubkey($source->pubkey);
@@ -46,6 +45,7 @@ class ArticleFactory
break;
case 'published_at':
$entity->setPublishedAt(\DateTimeImmutable::createFromFormat('U', (string)$tag[1]));
+ break;
case 't':
$entity->addTopic($tag[1]);
break;
diff --git a/src/Security/UserDTOProvider.php b/src/Security/UserDTOProvider.php
index 7f5d625..6b51855 100644
--- a/src/Security/UserDTOProvider.php
+++ b/src/Security/UserDTOProvider.php
@@ -3,8 +3,10 @@
namespace App\Security;
use App\Entity\User;
+use App\Enum\KindsEnum;
use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface;
+use swentel\nostr\Key\Key;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
@@ -52,22 +54,27 @@ readonly class UserDTOProvider implements UserProviderInterface
}
try {
- $data = $this->nostrClient->getLoginData($identifier);
+ $key = new Key();
+ $pubkey = $key->convertToHex($identifier);
+ $data = $this->nostrClient->getLoginData($pubkey);
foreach ($data as $d) {
$ev = $d->event;
- if ($ev->kind === 0) {
+ if ($ev->kind === KindsEnum::METADATA) {
$metadata = json_decode($ev->content);
}
- if ($ev->kind === 10002) {
+ if ($ev->kind === KindsEnum::RELAY_LIST) {
$relays = $ev->tags;
}
}
- } catch (\Exception) {
- // even if the user metadata not found, if sig is valid, login the pubkey
+ } catch (\Exception $e) {
+ // nothing to do here right now
+ }
+
+ if (is_null($metadata)) {
+ // if no metadata event, use what you have
$metadata = new \stdClass();
$metadata->name = substr($identifier, 0, 5) . ':' . substr($identifier, -5);
}
-
$user->setMetadata($metadata);
$user->setRelays($relays);
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index 5badb68..93dc6a1 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -9,7 +9,6 @@ use App\Factory\ArticleFactory;
use App\Repository\UserEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
-use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter;
@@ -21,9 +20,11 @@ use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
+use Symfony\Component\Serializer\Encoder\JsonEncoder;
+use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
+use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface;
-use Symfony\Contracts\Cache\ItemInterface;
class NostrClient
{
@@ -39,8 +40,12 @@ class NostrClient
{
// TODO configure read and write relays for logged in users from their 10002 events
$this->defaultRelaySet = new RelaySet();
- $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay
+ // $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public relay
+ // $this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public relay
$this->defaultRelaySet->addRelay(new Relay('wss://nos.lol')); // public relay
+ // $this->defaultRelaySet->addRelay(new Relay('wss://relay.snort.social')); // public relay
+ $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public relay
+ // $this->defaultRelaySet->addRelay(new Relay('wss://purplepag.es')); // public relay
}
public function getLoginData($npub)
@@ -51,10 +56,7 @@ class NostrClient
$filter->setKinds([KindsEnum::METADATA, KindsEnum::RELAY_LIST]);
$filter->setAuthors([$npub]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
- // use default aggregator relay
- $relay = new Relay('wss://purplepag.es');
-
- $request = new Request($relay, $requestMessage);
+ $request = new Request($this->defaultRelaySet, $requestMessage);
$response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set
@@ -73,46 +75,54 @@ class NostrClient
/**
* @throws \Exception
- * @throws InvalidArgumentException
*/
public function getNpubMetadata($npub)
{
- // cache metadata, only fetch new, if no cache hit
- return $this->cacheApp->get($npub.'-0', function (ItemInterface $item) use ($npub) {
- $item->expiresAfter(7000);
-
- $subscription = new Subscription();
- $subscriptionId = $subscription->setId();
- $filter = new Filter();
- $filter->setKinds([KindsEnum::METADATA]);
- $filter->setAuthors([$npub]);
- $requestMessage = new RequestMessage($subscriptionId, [$filter]);
- $relays = new RelaySet();
- $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator
- $relays->addRelay(new Relay('wss://relay.damus.io')); // major public
- $relays->addRelay(new Relay('wss://relay.snort.social')); // major public
+ $filter = new Filter();
+ $filter->setKinds([KindsEnum::METADATA]);
+ $filter->setAuthors([$npub]);
+ $filters = [$filter];
+ $subscription = new Subscription();
+ $requestMessage = new RequestMessage($subscription->getId(), $filters);
+ $relays = [
+ new Relay('wss://purplepag.es'),
+ new Relay('wss://theforest.nostr1.com'),
+ ];
+ $relaySet = new RelaySet();
+ $relaySet->setRelays($relays);
- $request = new Request($relays, $requestMessage);
+ $request = new Request($relaySet, $requestMessage);
+ $response = $request->send();
- $response = $request->send();
- // response is an array of arrays
- foreach ($response as $value) {
- foreach ($value as $item) {
- switch ($item->type) {
- case 'EVENT':
- return $item->event;
- case 'AUTH':
- throw new UnauthorizedHttpException('', 'Relay requires authentication');
- case 'ERROR':
- case 'NOTICE':
- throw new \Exception('An error occurred');
- default:
- return null;
- }
+ $meta = [];
+ // response is an array of arrays
+ foreach ($response as $value) {
+ foreach ($value as $item) {
+ switch ($item->type) {
+ case 'EVENT':
+ $meta[] = $item->event;
+ break;
+ case 'AUTH':
+ throw new UnauthorizedHttpException('', 'Relay requires authentication');
+ case 'ERROR':
+ case 'NOTICE':
+ throw new \Exception('An error occurred');
+ default:
+ // skip
}
}
- return null;
- });
+ }
+
+ if (count($meta) > 0) {
+ if (count($meta) > 1) {
+ // sort by date and pick newest
+ usort($meta, function($a, $b) {
+ return $b->created_at <=> $a->created_at;
+ });
+ }
+ return $meta[0];
+ }
+ return [];
}
public function getNpubLongForm($npub): void
@@ -132,7 +142,9 @@ class NostrClient
if ($user && $user->getRelays()) {
$relays = new RelaySet();
foreach ($user->getRelays() as $relayArr) {
- $relays->addRelay(new Relay($relayArr[1]));
+ if ($relayArr[2] == 'write') {
+ $relays->addRelay(new Relay($relayArr[1]));
+ }
}
}
@@ -184,8 +196,7 @@ class NostrClient
$user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet;
if ($user) {
- $relays = new RelaySet();
-
+ //$relays = new RelaySet();
}
$request = new Request($relays, $requestMessage);
@@ -217,16 +228,21 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
- if (empty($relayList)) {
- $relays = $this->defaultRelaySet;
- } else {
- $relays = new RelaySet();
- $relays->addRelay(new Relay($relayList[0]));
+ $relays = $this->defaultRelaySet;
+ if (!empty($relayList)) {
+ // $relays->addRelay(new Relay($relayList[0]));
}
- $request = new Request($relays, $requestMessage);
- $response = $request->send();
+ try {
+ $request = new Request($this->defaultRelaySet, $requestMessage);
+ $response = $request->send();
+ } catch (\Exception $e) {
+ // likely a problem with user's relays, go to defaults only
+ $request = new Request($this->defaultRelaySet, $requestMessage);
+ $response = $request->send();
+ }
+
// response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results
foreach ($response as $relayRes) {
@@ -282,28 +298,89 @@ class NostrClient
}
- public function getProfileEvents($npub): void
+ /**
+ * Save user metadata
+ */
+ private function saveMetadata($metadata): void
+ {
+ try {
+ $user = $this->serializer->deserialize($metadata->content, User::class, 'json');
+ $user->setNpub($metadata->pubkey);
+ } catch (\Exception $e) {
+ $this->logger->error('Deserialization of user data failed.', ['exception' => $e]);
+ return;
+ }
+
+ try {
+ $this->logger->info('Saving user', ['user' => $user]);
+ $this->userEntityRepository->findOrCreateByUniqueField($user);
+ $this->entityManager->flush();
+ } catch (\Exception $e) {
+ $this->logger->error($e->getMessage());
+ $this->managerRegistry->resetManager();
+ }
+
+ }
+
+ private function saveLongFormContent(mixed $filtered): void
+ {
+ foreach ($filtered as $wrapper) {
+ $article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event);
+ // check if event with same eventId already in DB
+ $saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]);
+ if (!$saved) {
+ try {
+ $this->logger->info('Saving article', ['article' => $article]);
+ $this->entityManager->persist($article);
+ $this->entityManager->flush();
+ } catch (\Exception $e) {
+ $this->logger->error($e->getMessage());
+ $this->managerRegistry->resetManager();
+ }
+ }
+ }
+ }
+
+ /**
+ *
+ * @return array
+ */
+ public function getNpubRelays($pubkey)
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
- $filter->setKinds([KindsEnum::METADATA, KindsEnum::FOLLOWS, KindsEnum::RELAY_LIST]);
- $filter->setAuthors([$npub]);
+ $filter->setKinds([KindsEnum::RELAY_LIST]);
+ $filter->setAuthors([$pubkey]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// TODO make relays configurable
$relays = new RelaySet();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator
$relays->addRelay(new Relay('wss://nos.lol')); // default public
+ $relays->addRelay(new Relay('wss://theforest.nostr1.com')); // default public
$request = new Request($relays, $requestMessage);
$response = $request->send();
+
// response is an array of arrays
foreach ($response as $value) {
foreach ($value as $item) {
switch ($item->type) {
case 'EVENT':
- dump($item);
+ $event = $item->event;
+ $relays = [];
+ foreach ($event->tags as $tag) {
+ if ($tag[0] === 'r') {
+ // if not already listed
+ if (!in_array($tag[1], $relays)) {
+ $relays[] = $tag[1];
+ }
+ }
+ }
+ if (!empty($relays)) {
+ return $relays;
+ }
break;
case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication');
@@ -315,48 +392,97 @@ class NostrClient
}
}
}
+ return [];
}
/**
- * Save user metadata
+ * @throws \Exception
*/
- private function saveMetadata($metadata): void
+ public function getComments($coordinate): array
{
- try {
- $user = $this->serializer->deserialize($metadata->content, User::class, 'json');
- $user->setNpub($metadata->pubkey);
- } catch (\Exception $e) {
- $this->logger->error('Deserialization of user data failed.', ['exception' => $e]);
- return;
- }
+ $list = [];
+ $parts = explode(':', $coordinate);
- try {
- $this->logger->info('Saving user', ['user' => $user]);
- $this->userEntityRepository->findOrCreateByUniqueField($user);
- $this->entityManager->flush();
- } catch (\Exception $e) {
- $this->logger->error($e->getMessage());
- $this->managerRegistry->resetManager();
- }
+ $subscription = new Subscription();
+ $subscriptionId = $subscription->setId();
+ $filter = new Filter();
+ $filter->setKinds([KindsEnum::COMMENTS, KindsEnum::TEXT_NOTE]);
+ $filter->setTag('#a', [$coordinate]);
+ $requestMessage = new RequestMessage($subscriptionId, [$filter]);
+ $request = new Request($this->defaultRelaySet, $requestMessage);
+
+ $response = $request->send();
+ // response is an array of arrays
+ foreach ($response as $value) {
+ foreach ($value as $item) {
+ switch ($item->type) {
+ case 'EVENT':
+ dump($item);
+ $list[] = $item;
+ break;
+ case 'AUTH':
+ throw new UnauthorizedHttpException('', 'Relay requires authentication');
+ case 'ERROR':
+ case 'NOTICE':
+ throw new \Exception('An error occurred');
+ default:
+ // nothing to do here
+ }
+ }
+ }
+ return $list;
}
- private function saveLongFormContent(mixed $filtered): void
+ /**
+ * @throws \Exception
+ */
+ public function getLongFormContentForPubkey(string $pubkey)
{
- foreach ($filtered as $wrapper) {
- $article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event);
- // check if event with same eventId already in DB
- $saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]);
- if (!$saved) {
- try {
- $this->logger->info('Saving article', ['article' => $article]);
- $this->entityManager->persist($article);
- $this->entityManager->flush();
- } catch (\Exception $e) {
- $this->logger->error($e->getMessage());
- $this->managerRegistry->resetManager();
+ $articles = [];
+ // get npub relays, then look for articles
+ $relayList = $this->getNpubRelays($pubkey);
+ $relaySet = $this->defaultRelaySet;
+ foreach ($relayList as $r) {
+ // if (str_starts_with($r, 'wss:')) $relaySet->addRelay(new Relay($r));
+ }
+ // look for last months long-form notes
+ $subscription = new Subscription();
+ $subscriptionId = $subscription->setId();
+ $filter = new Filter();
+ $filter->setKinds([KindsEnum::LONGFORM]);
+ $filter->setLimit(10);
+ $filter->setAuthors([$pubkey]);
+ $requestMessage = new RequestMessage($subscriptionId, [$filter]);
+ $request = new Request($relaySet, $requestMessage);
+
+ $response = $request->send();
+
+ // response is an array of arrays
+ foreach ($response as $value) {
+ foreach ($value as $item) {
+ if (is_array($item)) continue;
+ switch ($item->type) {
+ case 'EVENT':
+ $eventStr = json_encode($item->event);
+ // remap to the Event class
+ $encoders = [new JsonEncoder()];
+ $normalizers = [new ObjectNormalizer()];
+ $serializer = new Serializer($normalizers, $encoders);
+ /** @var \App\Entity\Event $event */
+ $event = $serializer->deserialize($eventStr, \App\Entity\Event::class, 'json');
+ $articles[] = $event;
+ break;
+ case 'AUTH':
+ // throw new UnauthorizedHttpException('', 'Relay requires authentication');
+ case 'ERROR':
+ case 'NOTICE':
+ // throw new \Exception('An error occurred');
+ default:
+ // nothing to do here
}
}
}
+ return $articles;
}
}
diff --git a/src/Twig/Components/GetCreditsComponent.php b/src/Twig/Components/GetCreditsComponent.php
new file mode 100644
index 0000000..ef0eb61
--- /dev/null
+++ b/src/Twig/Components/GetCreditsComponent.php
@@ -0,0 +1,42 @@
+tokenStorage->getToken()?->getUserIdentifier();
+ if ($npub) {
+ $this->creditsManager->addCredits($npub, 5, 'voucher');
+ }
+
+ // Dispatch event to notify parent
+ $this->emit('creditsAdded', [
+ 'credits' => 5,
+ ]);
+ }
+}
+
diff --git a/src/Twig/Components/Header.php b/src/Twig/Components/Header.php
index 1a7b934..934be0d 100644
--- a/src/Twig/Components/Header.php
+++ b/src/Twig/Components/Header.php
@@ -4,12 +4,28 @@ declare(strict_types=1);
namespace App\Twig\Components;
+use Psr\Cache\InvalidArgumentException;
+use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
class Header
{
- public function __construct()
+ public array $cats;
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function __construct(private readonly CacheInterface $redisCache)
{
+ $mag = $this->redisCache->get('magazine-newsroom-magazine-by-newsroom', function (){
+ return null;
+ });
+
+ $tags = $mag->getTags();
+
+ $this->cats = array_filter($tags, function($tag) {
+ return ($tag[0] === 'a');
+ });
}
}
diff --git a/src/Twig/Components/Molecules/Card.php b/src/Twig/Components/Molecules/Card.php
index 5134927..5673a6d 100644
--- a/src/Twig/Components/Molecules/Card.php
+++ b/src/Twig/Components/Molecules/Card.php
@@ -10,20 +10,11 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Card
{
- public string $tag = 'div';
public string $category = '';
public object $article;
- public object $user;
- public function __construct(private readonly EntityManagerInterface $entityManager, private readonly NostrClient $nostrClient)
+ public function __construct()
{
}
- public function mount(?string $npub = null): void
- {
- if ($npub) {
- $this->nostrClient->getMetadata();
- $this->user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]);
- }
- }
}
diff --git a/src/Twig/Components/Molecules/CategoryLink.php b/src/Twig/Components/Molecules/CategoryLink.php
new file mode 100644
index 0000000..eecc93d
--- /dev/null
+++ b/src/Twig/Components/Molecules/CategoryLink.php
@@ -0,0 +1,40 @@
+slug = $parts[2];
+ $cat = $this->redisCache->get('magazine-' . $parts[2], function (){
+ return null;
+ });
+
+ $tags = $cat->getTags();
+
+ $title = array_filter($tags, function($tag) {
+ return ($tag[0] === 'title');
+ });
+
+ $this->title = $title[array_key_first($title)][1];
+ } else {
+ dump($coordinate);die();
+ }
+
+ }
+
+}
diff --git a/src/Twig/Components/Molecules/UserFromNpub.php b/src/Twig/Components/Molecules/UserFromNpub.php
index 7ef3cd3..8465063 100644
--- a/src/Twig/Components/Molecules/UserFromNpub.php
+++ b/src/Twig/Components/Molecules/UserFromNpub.php
@@ -5,7 +5,6 @@ namespace App\Twig\Components\Molecules;
use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
-use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
@@ -13,9 +12,9 @@ final class UserFromNpub
{
public string $pubkey;
public string $npub;
- public ?array $user = null;
+ public $user = null;
- public function __construct(private readonly NostrClient $nostrClient, private readonly CacheInterface $redisCache)
+ public function __construct(private readonly NostrClient $nostrClient)
{
}
@@ -26,16 +25,10 @@ final class UserFromNpub
$this->npub = $keys->convertPublicKeyToBech32($pubkey);
try {
- $this->user = $this->redisCache->get('user_' . $this->npub, function () {
- try {
- $meta = $this->nostrClient->getNpubMetadata($this->npub);
- return (array) json_decode($meta->content);
- } catch (InvalidArgumentException|\Exception) {
- return null;
- }
- });
- } catch (InvalidArgumentException $e) {
- $this->user = null;
+ $meta = $this->nostrClient->getNpubMetadata($this->pubkey);
+ $this->user = (array) json_decode($meta->content);
+ } catch (InvalidArgumentException|\Exception) {
+ // nothing to do
}
}
}
diff --git a/src/Twig/Components/Organisms/Comments.php b/src/Twig/Components/Organisms/Comments.php
new file mode 100644
index 0000000..61f3b4d
--- /dev/null
+++ b/src/Twig/Components/Organisms/Comments.php
@@ -0,0 +1,25 @@
+list = $this->nostrClient->getComments($current);
+ }
+}
diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php
new file mode 100644
index 0000000..b9a2d28
--- /dev/null
+++ b/src/Twig/Components/Organisms/FeaturedList.php
@@ -0,0 +1,52 @@
+redisCache->get('magazine-' . $parts[2], function (){
+ throw new \Exception('Not found');
+ });
+
+ foreach ($catIndex->getTags() as $tag) {
+ if ($tag[0] === 'title') {
+ $this->title = $tag[1];
+ }
+ if ($tag[0] === 'a') {
+ $parts = explode(':', $tag[1]);
+ if (count($parts) === 3) {
+ $fieldQuery = new MatchQuery();
+ $fieldQuery->setFieldQuery('slug', $parts[2]);
+ $res = $this->finder->find($fieldQuery);
+ $this->list[] = $res[0];
+ }
+ }
+ if (count($this->list) > 3) {
+ break;
+ }
+ }
+ }
+}
diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php
index 3900f1c..abf9610 100644
--- a/src/Twig/Components/SearchComponent.php
+++ b/src/Twig/Components/SearchComponent.php
@@ -2,8 +2,15 @@
namespace App\Twig\Components;
+use App\Credits\Service\CreditsManager;
use FOS\ElasticaBundle\Finder\FinderInterface;
+use Psr\Cache\InvalidArgumentException;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveAction;
+use Symfony\UX\LiveComponent\Attribute\LiveArg;
+use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
@@ -14,30 +21,77 @@ final class SearchComponent
#[LiveProp(writable: true)]
public string $query = '';
+ public array $results = [];
public bool $interactive = true;
- private FinderInterface $finder;
+ public int $credits = 0;
+ public ?string $npub = null;
- public function __construct(FinderInterface $finder)
+ #[LiveProp]
+ public int $vol = 0;
+
+ public function __construct(
+ private readonly FinderInterface $finder,
+ private readonly CreditsManager $creditsManager,
+ private readonly TokenStorageInterface $tokenStorage,
+ private readonly LoggerInterface $logger
+ )
+ {
+ $token = $this->tokenStorage->getToken();
+ $this->npub = $token?->getUserIdentifier();
+ }
+
+ public function mount(): void
{
- $this->finder = $finder;
+ if ($this->npub) {
+ try {
+ $this->credits = $this->creditsManager->getBalance($this->npub);
+ $this->logger->info($this->credits);
+ } catch (InvalidArgumentException $e) {
+ $this->logger->error($e);
+ $this->credits = $this->creditsManager->resetBalance($this->npub);
+ }
+ }
}
- public function getResults()
+ /**
+ * @throws InvalidArgumentException
+ */
+ #[LiveAction]
+ public function search(): void
{
+ $this->logger->info("Query: {$this->query}, npub: {$this->npub}");
+
if (empty($this->query)) {
- return [];
+ $this->results = [];
+ return;
}
- $res = $this->finder->find($this->query, 12); // Limit to 10 results
- // filter out items with bad slugs
- $filtered = array_filter($res, function($r) {
- return !str_contains($r->getSlug(), '/');
- });
+ try {
+ $this->credits = $this->creditsManager->getBalance($this->npub);
+ } catch (InvalidArgumentException $e) {
+ $this->credits = $this->creditsManager->resetBalance($this->npub);
+ }
+
+ if (!$this->creditsManager->canAfford($this->npub, 1)) {
+ $this->results = [];
+ return;
+ }
+ $this->creditsManager->spendCredits($this->npub, 1, 'search');
+ $this->credits--;
- return $filtered;
+ $this->results = array_filter(
+ $this->finder->find($this->query, 12),
+ fn($r) => !str_contains($r->getSlug(), '/')
+ );
+ }
+
+ #[LiveListener('creditsAdded')]
+ public function incrementCreditsCount(array $data): void
+ {
+ $this->credits += $data['credits'];
}
}
diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php
new file mode 100644
index 0000000..bfac2f3
--- /dev/null
+++ b/src/Twig/Filters.php
@@ -0,0 +1,24 @@
+addExtension(new NostrSchemeExtension($this->bech32Decoder));
$environment->addExtension(new SmartPunctExtension());
+ $environment->addExtension(new RawImageLinkExtension());
$environment->addExtension(new AutolinkExtension());
if ($headingsCount > 3) {
$environment->addExtension(new HeadingPermalinkExtension());
@@ -65,7 +67,7 @@ class Converter
// Instantiate the converter engine and start converting some Markdown!
$converter = new MarkdownConverter($environment);
$content = html_entity_decode($markdown);
-
+
return $converter->convert($content);
}
diff --git a/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php b/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php
new file mode 100644
index 0000000..049b247
--- /dev/null
+++ b/src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php
@@ -0,0 +1,14 @@
+addInlineParser(new RawImageLinkParser());
+ }
+}
diff --git a/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php b/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php
new file mode 100644
index 0000000..ad89224
--- /dev/null
+++ b/src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php
@@ -0,0 +1,35 @@
+getCursor();
+ $match = $inlineContext->getFullMatch();
+ // Create an element instead of a text link
+ $image = new Image($match, '');
+ $paragraph = new Paragraph();
+ $paragraph->appendChild($image);
+ $inlineContext->getContainer()->appendChild($paragraph);
+
+ // Advance the cursor to consume the matched part (important!)
+ $cursor->advanceBy(strlen($match));
+
+ return true;
+ }
+}
diff --git a/templates/admin/transactions.html.twig b/templates/admin/transactions.html.twig
new file mode 100644
index 0000000..64f654d
--- /dev/null
+++ b/templates/admin/transactions.html.twig
@@ -0,0 +1,36 @@
+{% extends 'base.html.twig' %}
+
+{% block title %}Credit Transactions{% endblock %}
+
+{% block body %}
+ Credit Transactions
+
+
+
+
+ ID
+ NPub
+ Type
+ Amount
+ Reason
+ Timestamp
+
+
+
+ {% for tx in transactions %}
+
+ {{ tx.id }}
+ {{ tx.npub|shortenNpub }}
+ {{ tx.type }}
+ {{ tx.amount }}
+ {{ tx.reason ?: '—' }}
+ {{ tx.createdAt|date('Y-m-d H:i:s') }}
+
+ {% else %}
+
+ No transactions found.
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/templates/components/GetCreditsComponent.html.twig b/templates/components/GetCreditsComponent.html.twig
new file mode 100644
index 0000000..3371230
--- /dev/null
+++ b/templates/components/GetCreditsComponent.html.twig
@@ -0,0 +1,6 @@
+
+
+ Get 5 Free Credits
+
+
diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig
index 75d2d26..1d0acc5 100644
--- a/templates/components/Header.html.twig
+++ b/templates/components/Header.html.twig
@@ -2,21 +2,9 @@
diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig
index 9eab0dd..a8e377e 100644
--- a/templates/components/Molecules/Card.html.twig
+++ b/templates/components/Molecules/Card.html.twig
@@ -1,28 +1,27 @@
{% if article is defined %}
-<{{ tag }} {{ attributes }}>
-
diff --git a/templates/home.html.twig b/templates/home.html.twig
index 9cb524a..ddfe0b0 100644
--- a/templates/home.html.twig
+++ b/templates/home.html.twig
@@ -5,9 +5,9 @@
{% block body %}
{# content #}
-
- {# replace list with featured #}
-
+ {% for item in indices %}
+
+ {% endfor %}
{% endblock %}
{% block aside %}
diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig
index b5143ef..e43379a 100644
--- a/templates/pages/article.html.twig
+++ b/templates/pages/article.html.twig
@@ -16,7 +16,9 @@
{% if article.publishedAt is not null %}
{{ article.publishedAt|date('F j, Y') }}
{% else %}
- {{ article.createdAt|date('F j, Y') }}
+
+{# #}
+ {{ article.createdAt|date('F j, Y') }}
{% endif %}
@@ -49,9 +51,13 @@
{# #}
{# {{ article.content }}#}
{# #}
+
{% endblock %}
{% block aside %}
+ {% if is_granted('ROLE_ADMIN') %}
+ 30032:{{ article.pubkey }}:{{ article.slug }}
+ {% endif %}
{# Suggestions #}
{# #}
{% endblock %}
diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig
index 82f83da..10a31c6 100644
--- a/templates/pages/author.html.twig
+++ b/templates/pages/author.html.twig
@@ -13,37 +13,37 @@
{% endif %}
- {% if app.user and app.user.userIdentifier is same as npub %}
-
-
Purchase Search Credits
-
-
-
Amount: {{ amount ?? 0 }} sats
-
Status: Pending
-
-
- {# You can access the width and height via the matrix #}
- {# Replace the string with the invoice #}
- {% set qrCode = qr_code_result('My QR Code') %}
-
-
-
-
Check Payment Status
-
-
-
- {% endif %}
+{# {% if app.user and app.user.userIdentifier is same as npub %}#}
+{# #}
+{#
Purchase Search Credits #}
+
+{#
#}
+{#
Amount: {{ amount ?? 0 }} sats
#}
+{#
Status: Pending
#}
+{#
#}
+
+{# #}{# You can access the width and height via the matrix #}
+{# #}{# Replace the string with the invoice #}
+{# {% set qrCode = qr_code_result('My QR Code') %}#}
+{#
#}
+
+{#
#}
+{#
Check Payment Status #}
+
+{# #}
+{#
#}
+{# {% endif %}#}
{% if nzine %}
View as N-Zine
diff --git a/templates/pages/search.html.twig b/templates/pages/search.html.twig
index 32fbc33..05a3cdd 100644
--- a/templates/pages/search.html.twig
+++ b/templates/pages/search.html.twig
@@ -6,8 +6,3 @@
{% block body %}
{% endblock %}
-
-{% block aside %}
-{# Magazines #}
-{# #}
-{% endblock %}
diff --git a/templates/static/about.html.twig b/templates/static/about.html.twig
index 469f2e2..8727143 100644
--- a/templates/static/about.html.twig
+++ b/templates/static/about.html.twig
@@ -7,7 +7,8 @@
About Decent Newsroom
Rebuilding Trust in Journalism
- The internet, and especially social media, made news instant and abundant—but also cheap and unreliable. The value of simply reporting what happened and where has dropped to zero, buried under waves of misinformation, clickbait, and AI-generated noise. Worse, sorting truth from falsehood now costs more than spreading lies.
+ The internet, and especially social media, made news instant and abundant, but also cheap and unreliable. The value of simply reporting what happened and where has dropped to zero,
+ buried under waves of misinformation, clickbait, and AI-generated noise. Worse, sorting truth from falsehood now costs more than spreading lies.
Decent Newsroom is our answer to this crisis. We are building a curated, decentralized magazine featuring high-quality, long-form journalism published on Nostr.
diff --git a/templates/static/roadmap.html.twig b/templates/static/roadmap.html.twig
index bfb7941..bd0c35f 100644
--- a/templates/static/roadmap.html.twig
+++ b/templates/static/roadmap.html.twig
@@ -5,13 +5,15 @@
{% block body %}
Roadmap: The Future of Decent Newsroom
- We are building a decentralized, high-value journalism platform step by step. Each phase unlocks new features, empowering writers, publishers, and readers to engage with quality, independent reporting in a whole new way.
+ We are building a decentralized, high-value journalism platform step by step. Each phase unlocks new features,
+ empowering writers, publishers, and readers to engage with quality, independent reporting in a whole new way.
v0.1.0 – Preprint Current
A first glimpse into the future of journalism
-
This early version of First Edition Newsroom is all about discovery. We’re launching a sample magazine to showcase what’s possible.
+
This early version of Decent Newsroom is all about discovery. We’re launching a sample magazine
+ to showcase what’s possible.
Hand-curated, high-quality articles in magazine format
Full-text search to find the stories that matter to you
@@ -28,11 +30,11 @@
This milestone version introduces key features that bring Decent Newsroom to life:
Zaps
- Search and indexing requests enabled – writers and publishers can request inclusion
- Payable subscriptions and indexing via Lightning invoices
- Built-in content editor – publishers can create directly on the platform
+ Content submissions
+ Subscriptions for premium access
+ Built-in content editor so publishers can create directly on the platform
- With First Edition, journalism becomes more than just content — it becomes an ecosystem.
+ With First Edition, journalism becomes more than just content, it becomes an ecosystem.
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml
index 3960505..8e3d628 100644
--- a/translations/messages.en.yaml
+++ b/translations/messages.en.yaml
@@ -11,3 +11,6 @@ heading:
editNzine: 'Edit your N-Zine'
search: 'Search'
index: 'Index'
+credit:
+ balance: '{0} No credits|{1} 1 credit|]1,Inf] %count% credits'
+