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

+ + + + + + + + + + + + + + {% for tx in transactions %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDNPubTypeAmountReasonTimestamp
{{ tx.id }}{{ tx.npub|shortenNpub }}{{ tx.type }}{{ tx.amount }}{{ tx.reason ?: '—' }}{{ tx.createdAt|date('Y-m-d H:i:s') }}
No transactions found.
+{% 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 @@ +
+ +
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 }}> -
- {% if category %}{{ category }}{% endif %} - {% if article.image %} - - {% endif %} +
+ + +
+ {% if category %}{{ category }}{% endif %} + {% if article.image %} + Cover image for {{ article.title }} + {% endif %} +
+
+

{{ article.title }}

+

+ {{ article.summary }} +

+
+
+
-
-{# {{ article.createdAt|date('F j') }}#} -

{{ article.title }}

-

- {{ article.summary }} -

-
- -{##} -{% endif %} -{% if user is defined %} -<{{ tag }} {{ attributes }}> -
-

{{ user.name }}

-

{{ user.about }}

-
- {% endif %} diff --git a/templates/components/Molecules/CategoryLink.html.twig b/templates/components/Molecules/CategoryLink.html.twig new file mode 100644 index 0000000..779defb --- /dev/null +++ b/templates/components/Molecules/CategoryLink.html.twig @@ -0,0 +1,4 @@ + + {{ title }} + diff --git a/templates/components/Molecules/UserFromNpub.html.twig b/templates/components/Molecules/UserFromNpub.html.twig index c09dea4..4638148 100644 --- a/templates/components/Molecules/UserFromNpub.html.twig +++ b/templates/components/Molecules/UserFromNpub.html.twig @@ -1,5 +1,5 @@ {% if user %} - + {% else %} - {{ npub }} + {{ npub|shortenNpub }} {% endif %} diff --git a/templates/components/Organisms/CardList.html.twig b/templates/components/Organisms/CardList.html.twig index 2be9256..2309e00 100644 --- a/templates/components/Organisms/CardList.html.twig +++ b/templates/components/Organisms/CardList.html.twig @@ -1,7 +1,7 @@
{% for item in list %} {% if item.slug is not empty %} - + {% endif %} {% endfor %}
diff --git a/templates/components/Organisms/Comments.html.twig b/templates/components/Organisms/Comments.html.twig new file mode 100644 index 0000000..0863c44 --- /dev/null +++ b/templates/components/Organisms/Comments.html.twig @@ -0,0 +1,7 @@ +
+ {% for item in list %} +
+ {{ item.content }} +
+ {% endfor %} +
diff --git a/templates/components/Organisms/FeaturedList.html.twig b/templates/components/Organisms/FeaturedList.html.twig new file mode 100644 index 0000000..ae6962e --- /dev/null +++ b/templates/components/Organisms/FeaturedList.html.twig @@ -0,0 +1,43 @@ +
+ {% if list %} + +
+ +
+ {% for item in list %} + {% if item != feature %} + + {% endif %} + {% endfor %} +
+
+ {% endif %} +
diff --git a/templates/components/SearchComponent.html.twig b/templates/components/SearchComponent.html.twig index 008ab6c..6755bb9 100644 --- a/templates/components/SearchComponent.html.twig +++ b/templates/components/SearchComponent.html.twig @@ -1,28 +1,40 @@
- {% if interactive %} - - + {% if interactive %} +
+ +
+ + {{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }} + +
+
- -
- {{ 'text.searching'|trans }} -
- {% endif %} + +
+
+
+
+ {{ 'text.searching'|trans }} +
+ {% endif %} - - {% if this.results is not empty %} - - {% elseif this.query is not empty %} -

{{ 'text.noResults'|trans }}

- {% endif %} -
+ + {% if this.results is not empty %} + + {% elseif this.query is not empty %} +

{{ 'text.noResults'|trans }}

+ {% endif %} + {% block aside %} + {% if credits == 0 %} + + {% endif %} + {% endblock %} +
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') %} - invoice-qr - -
- - - -
- {% 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') %}#} +{# invoice-qr#} + +{#
#} +{# #} + +{# #} +{#
#} +{# {% 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' +