Browse Source

Moving things around, updating MD parser, content and static pages

imwald
Nuša Pukšič 8 months ago
parent
commit
f4fe8e7035
  1. 10
      compose.yaml
  2. 181
      composer.lock
  3. 1
      config/packages/cache.yaml
  4. 6
      config/packages/doctrine.yaml
  5. 4
      config/services.yaml
  6. 36
      docker/cron/Dockerfile
  7. 94
      docker/cron/README.md
  8. 1
      docker/cron/crontab
  9. 8
      docker/cron/index_articles.sh
  10. 38
      migrations/Version20250509100039.php
  11. 35
      migrations/Version20250509100408.php
  12. 45
      src/Command/DatabaseCleanupCommand.php
  13. 52
      src/Command/ElevateUserCommand.php
  14. 29
      src/Command/GetArticlesCommand.php
  15. 64
      src/Command/IndexArticlesCommand.php
  16. 41
      src/Command/MarkAsIndexedCommand.php
  17. 86
      src/Command/NostrEventFromYamlDefinitionCommand.php
  18. 69
      src/Command/QualityCheckArticlesCommand.php
  19. 24
      src/Controller/Administration/CreditTransactionController.php
  20. 79
      src/Controller/ArticleController.php
  21. 14
      src/Controller/AuthorController.php
  22. 62
      src/Controller/DefaultController.php
  23. 100
      src/Credits/Entity/CreditTransaction.php
  24. 68
      src/Credits/Service/CreditsManager.php
  25. 90
      src/Credits/Util/RedisCreditStore.php
  26. 2
      src/Factory/ArticleFactory.php
  27. 19
      src/Security/UserDTOProvider.php
  28. 256
      src/Service/NostrClient.php
  29. 42
      src/Twig/Components/GetCreditsComponent.php
  30. 18
      src/Twig/Components/Header.php
  31. 11
      src/Twig/Components/Molecules/Card.php
  32. 40
      src/Twig/Components/Molecules/CategoryLink.php
  33. 17
      src/Twig/Components/Molecules/UserFromNpub.php
  34. 25
      src/Twig/Components/Organisms/Comments.php
  35. 52
      src/Twig/Components/Organisms/FeaturedList.php
  36. 76
      src/Twig/Components/SearchComponent.php
  37. 24
      src/Twig/Filters.php
  38. 2
      src/Util/CommonMark/Converter.php
  39. 14
      src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php
  40. 35
      src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php
  41. 36
      templates/admin/transactions.html.twig
  42. 6
      templates/components/GetCreditsComponent.html.twig
  43. 18
      templates/components/Header.html.twig
  44. 27
      templates/components/Molecules/Card.html.twig
  45. 4
      templates/components/Molecules/CategoryLink.html.twig
  46. 4
      templates/components/Molecules/UserFromNpub.html.twig
  47. 2
      templates/components/Organisms/CardList.html.twig
  48. 7
      templates/components/Organisms/Comments.html.twig
  49. 43
      templates/components/Organisms/FeaturedList.html.twig
  50. 22
      templates/components/SearchComponent.html.twig
  51. 6
      templates/home.html.twig
  52. 8
      templates/pages/article.html.twig
  53. 62
      templates/pages/author.html.twig
  54. 5
      templates/pages/search.html.twig
  55. 3
      templates/static/about.html.twig
  56. 14
      templates/static/roadmap.html.twig
  57. 3
      translations/messages.en.yaml

10
compose.yaml

@ -5,7 +5,7 @@ services: @@ -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: @@ -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:

181
composer.lock generated

@ -506,26 +506,29 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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",

1
config/packages/cache.yaml

@ -15,3 +15,4 @@ framework: @@ -15,3 +15,4 @@ framework:
pools:
#my.dedicated.cache: null
subscriptions.cache: null
credits.cache: null

6
config/packages/doctrine.yaml

@ -24,6 +24,12 @@ doctrine: @@ -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

4
config/services.yaml

@ -66,3 +66,7 @@ services: @@ -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'

36
docker/cron/Dockerfile

@ -0,0 +1,36 @@ @@ -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"]

94
docker/cron/README.md

@ -0,0 +1,94 @@ @@ -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.

1
docker/cron/crontab

@ -0,0 +1 @@ @@ -0,0 +1 @@
0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1

8
docker/cron/index_articles.sh

@ -0,0 +1,8 @@ @@ -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

38
migrations/Version20250509100039.php

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250509100039 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

35
migrations/Version20250509100408.php

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250509100408 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

45
src/Command/DatabaseCleanupCommand.php

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'db:cleanup', description: 'Remove articles with do_not_index rating')]
class DatabaseCleanupCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$repository = $this->entityManager->getRepository(Article::class);
$items = $repository->findBy(['indexStatus' => IndexStatusEnum::DO_NOT_INDEX]);
if (empty($items)) {
$output->writeln('<info>No items found.</info>');
return Command::SUCCESS;
}
foreach ($items as $item) {
$this->entityManager->remove($item);
}
$this->entityManager->flush();
$output->writeln('<comment>Deleted ' . count($items) . ' items.</comment>');
return Command::SUCCESS;
}
}

52
src/Command/ElevateUserCommand.php

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'user:elevate',
description: 'Assign a role to user'
)]
class ElevateUserCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function configure(): void
{
$this
->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;
}
}

29
src/Command/GetArticlesCommand.php

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
<?php
namespace App\Command;
use App\Service\NostrClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'articles:get',
description: 'Pull articles from a default relay',
)]
class GetArticlesCommand extends Command
{
public function __construct(private readonly NostrClient $nostrClient)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$this->nostrClient->getLongFormContent();
return Command::SUCCESS;
}
}

64
src/Command/IndexArticlesCommand.php

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'articles:index', description: 'Persist selected articles to Elastic')]
class IndexArticlesCommand extends Command
{
private const BATCH_SIZE = 100; // Define batch size
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ObjectPersisterInterface $itemPersister)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$articles = $this->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);
}
}

41
src/Command/MarkAsIndexedCommand.php

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'articles:indexed', description: 'Mark articles as indexed after populating')]
class MarkAsIndexedCommand extends Command
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$articles = $this->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;
}
}

86
src/Command/NostrEventFromYamlDefinitionCommand.php

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Command;
use swentel\nostr\Event\Event;
use swentel\nostr\Sign\Sign;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Cache\CacheInterface;
#[AsCommand(name: 'app:yaml_to_nostr', description: 'Traverses folders, converts YAML files to JSON using object mapping, and saves the result in Redis cache.')]
class NostrEventFromYamlDefinitionCommand extends Command
{
const private_key = 'nsec17ygfd40ckdwmrl4mzhnzzdr3c8j5kvnavgrct35hglha9ue396dslsterv';
public function __construct(private readonly CacheInterface $redisCache)
{
parent::__construct();
}
protected function configure(): void
{
$this
->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('<comment>No YAML files found in the specified directory.</comment>');
return Command::SUCCESS;
}
foreach ($finder as $file) {
$filePath = $file->getRealPath();
$output->writeln("<info>Processing file: $filePath</info>");
$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("<info>Saved index.</info>");
} catch (\Exception $e) {
$output->writeln("<error>Error deserializing YAML in file: $filePath. Message: {$e->getMessage()}</error>");
continue;
}
}
$output->writeln('<info>Conversion complete.</info>');
return Command::SUCCESS;
}
}

69
src/Command/QualityCheckArticlesCommand.php

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Persister\ObjectPersister;
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'articles:qa', description: 'Mark articles by quality and select which to index')]
class QualityCheckArticlesCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager
)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$articles = $this->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;
}
}

24
src/Controller/Administration/CreditTransactionController.php

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Controller\Administration;
use App\Credits\Entity\CreditTransaction;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class CreditTransactionController extends AbstractController
{
#[Route('/admin/transactions', name: 'admin_credit_transactions')]
public function index(EntityManagerInterface $em): Response
{
$transactions = $em->getRepository(CreditTransaction::class)->findBy([], ['createdAt' => 'DESC']);
return $this->render('admin/transactions.html.twig', [
'transactions' => $transactions,
]);
}
}

79
src/Controller/ArticleController.php

@ -12,16 +12,17 @@ use Doctrine\ORM\EntityManagerInterface; @@ -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 @@ -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 @@ -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 @@ -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,6 +126,7 @@ class ArticleController extends AbstractController @@ -120,6 +126,7 @@ class ArticleController extends AbstractController
// return $b->getCreatedAt() <=> $a->getCreatedAt();
// });
try {
$meta = $nostrClient->getNpubMetadata($article->getPubkey());
if ($meta?->content) {
$author = (array) json_decode($meta->content);
@ -128,10 +135,14 @@ class ArticleController extends AbstractController @@ -128,10 +135,14 @@ class ArticleController extends AbstractController
'name' => '<anonymous>'
];
}
} 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 @@ -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 @@ -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 @@ -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(),
]);
}

14
src/Controller/AuthorController.php

@ -25,15 +25,13 @@ class AuthorController extends AbstractController @@ -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 @@ -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 @@ -55,7 +53,7 @@ class AuthorController extends AbstractController
'articles' => $articles,
'nzine' => null,
'nzines' => null,
'idx' => $indices
'idx' => null
]);
}

62
src/Controller/DefaultController.php

@ -4,14 +4,13 @@ declare(strict_types=1); @@ -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 @@ -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)
]);
}

100
src/Credits/Entity/CreditTransaction.php

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
<?php
namespace App\Credits\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class CreditTransaction
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(length: 64)]
private string $npub;
#[ORM\Column(type: 'integer')]
private int $amount;
#[ORM\Column(length: 16)]
private string $type; // 'credit' or 'debit'
#[ORM\Column(type: 'datetime')]
private \DateTime $createdAt;
#[ORM\Column(nullable: true)]
private ?string $reason = null;
public function __construct(string $npub, int $amount, string $type, ?string $reason = null)
{
$this->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;
}
}

68
src/Credits/Service/CreditsManager.php

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
<?php
namespace App\Credits\Service;
use App\Credits\Entity\CreditTransaction;
use App\Credits\Util\RedisCreditStore;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\InvalidArgumentException;
readonly class CreditsManager
{
public function __construct(
private RedisCreditStore $redisStore,
private EntityManagerInterface $em
) {}
/**
* @throws InvalidArgumentException
*/
public function getBalance(string $npub): int
{
return $this->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();
}
}

90
src/Credits/Util/RedisCreditStore.php

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
<?php
namespace App\Credits\Util;
use App\Credits\Entity\CreditTransaction;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
readonly class RedisCreditStore
{
public function __construct(
private CacheInterface $creditsCache,
private EntityManagerInterface $em
) {}
private function key(string $npub): string
{
return 'credits_' . $npub;
}
/**
* @throws InvalidArgumentException
*/
public function resetBalance(string $npub): int
{
$this->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);
}
}

2
src/Factory/ArticleFactory.php

@ -21,7 +21,6 @@ class ArticleFactory @@ -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 @@ -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;

19
src/Security/UserDTOProvider.php

@ -3,8 +3,10 @@ @@ -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 @@ -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);

256
src/Service/NostrClient.php

@ -9,7 +9,6 @@ use App\Factory\ArticleFactory; @@ -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; @@ -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 @@ -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 @@ -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,47 +75,55 @@ class NostrClient @@ -73,47 +75,55 @@ 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
$request = new Request($relays, $requestMessage);
$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($relaySet, $requestMessage);
$response = $request->send();
$meta = [];
// response is an array of arrays
foreach ($response as $value) {
foreach ($value as $item) {
switch ($item->type) {
case 'EVENT':
return $item->event;
$meta[] = $item->event;
break;
case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR':
case 'NOTICE':
throw new \Exception('An error occurred');
default:
return null;
// 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,9 +142,11 @@ class NostrClient @@ -132,9 +142,11 @@ class NostrClient
if ($user && $user->getRelays()) {
$relays = new RelaySet();
foreach ($user->getRelays() as $relayArr) {
if ($relayArr[2] == 'write') {
$relays->addRelay(new Relay($relayArr[1]));
}
}
}
$request = new Request($relays, $requestMessage);
@ -184,8 +196,7 @@ class NostrClient @@ -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 @@ -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]));
if (!empty($relayList)) {
// $relays->addRelay(new Relay($relayList[0]));
}
$request = new Request($relays, $requestMessage);
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 @@ -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 @@ -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;
}
}

42
src/Twig/Components/GetCreditsComponent.php

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
<?php
namespace App\Twig\Components;
use App\Credits\Service\CreditsManager;
use Psr\Cache\InvalidArgumentException;
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\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class GetCreditsComponent
{
use DefaultActionTrait;
use ComponentToolsTrait;
public function __construct(
private readonly CreditsManager $creditsManager,
private readonly TokenStorageInterface $tokenStorage)
{
}
/**
* @throws InvalidArgumentException
*/
#[LiveAction]
public function grantVoucher(): void
{
$npub = $this->tokenStorage->getToken()?->getUserIdentifier();
if ($npub) {
$this->creditsManager->addCredits($npub, 5, 'voucher');
}
// Dispatch event to notify parent
$this->emit('creditsAdded', [
'credits' => 5,
]);
}
}

18
src/Twig/Components/Header.php

@ -4,12 +4,28 @@ declare(strict_types=1); @@ -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');
});
}
}

11
src/Twig/Components/Molecules/Card.php

@ -10,20 +10,11 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -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]);
}
}
}

40
src/Twig/Components/Molecules/CategoryLink.php

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
<?php
namespace App\Twig\Components\Molecules;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class CategoryLink
{
public string $title;
public string $slug;
public function __construct(private CacheInterface $redisCache)
{
}
public function mount($coordinate): void
{
if (key_exists(1, $coordinate)) {
$parts = explode(':', $coordinate[1]);
$this->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();
}
}
}

17
src/Twig/Components/Molecules/UserFromNpub.php

@ -5,7 +5,6 @@ namespace App\Twig\Components\Molecules; @@ -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 @@ -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 @@ -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);
$meta = $this->nostrClient->getNpubMetadata($this->pubkey);
$this->user = (array) json_decode($meta->content);
} catch (InvalidArgumentException|\Exception) {
return null;
}
});
} catch (InvalidArgumentException $e) {
$this->user = null;
// nothing to do
}
}
}

25
src/Twig/Components/Organisms/Comments.php

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
<?php
namespace App\Twig\Components\Organisms;
use App\Service\NostrClient;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Comments
{
public array $list = [];
public function __construct(private readonly NostrClient $nostrClient)
{
}
/**
* @throws \Exception
*/
public function mount($current): void
{
// fetch comments, kind 1111
$this->list = $this->nostrClient->getComments($current);
}
}

52
src/Twig/Components/Organisms/FeaturedList.php

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
<?php
namespace App\Twig\Components\Organisms;
use Elastica\Query\MatchQuery;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Event\Event;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class FeaturedList
{
public $category;
public string $title;
public array $list = [];
public function __construct(private readonly CacheInterface $redisCache, private readonly FinderInterface $finder)
{
}
/**
* @throws InvalidArgumentException
*/
public function mount($category): void
{
$parts = explode(':', $category[1]);
/** @var Event $catIndex */
$catIndex = $this->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;
}
}
}
}

76
src/Twig/Components/SearchComponent.php

@ -2,8 +2,15 @@ @@ -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 @@ -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
)
{
$this->finder = $finder;
$token = $this->tokenStorage->getToken();
$this->npub = $token?->getUserIdentifier();
}
public function getResults()
public function mount(): void
{
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);
}
}
}
/**
* @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'];
}
}

24
src/Twig/Filters.php

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigFilter;
class Filters extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('shortenNpub', [$this, 'shortenNpub']),
];
}
public function shortenNpub(string $npub): string
{
return substr($npub, 0, 8) . '…' . substr($npub, -4);
}
}

2
src/Util/CommonMark/Converter.php

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
namespace App\Util\CommonMark;
use App\Util\Bech32\Bech32Decoder;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Exception\CommonMarkException;
@ -56,6 +57,7 @@ class Converter @@ -56,6 +57,7 @@ class Converter
// create a custom extension, that handles nostr mentions
$environment->addExtension(new NostrSchemeExtension($this->bech32Decoder));
$environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new RawImageLinkExtension());
$environment->addExtension(new AutolinkExtension());
if ($headingsCount > 3) {
$environment->addExtension(new HeadingPermalinkExtension());

14
src/Util/CommonMark/ImagesExtension/RawImageLinkExtension.php

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
<?php
namespace App\Util\CommonMark\ImagesExtension;
use League\CommonMark\Environment\EnvironmentBuilderInterface;
use League\CommonMark\Extension\ExtensionInterface;
class RawImageLinkExtension implements ExtensionInterface
{
public function register(EnvironmentBuilderInterface $environment): void
{
$environment->addInlineParser(new RawImageLinkParser());
}
}

35
src/Util/CommonMark/ImagesExtension/RawImageLinkParser.php

@ -0,0 +1,35 @@ @@ -0,0 +1,35 @@
<?php
namespace App\Util\CommonMark\ImagesExtension;
use League\CommonMark\Node\Block\Paragraph;
use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
use League\CommonMark\Extension\CommonMark\Node\Inline\SoftBreak;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;
class RawImageLinkParser implements InlineParserInterface
{
public function getMatchDefinition(): InlineParserMatch
{
// Match URLs ending with an image extension
return InlineParserMatch::regex('https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?=\s|$)');
}
public function parse(InlineParserContext $inlineContext): bool
{
$cursor = $inlineContext->getCursor();
$match = $inlineContext->getFullMatch();
// Create an <img> 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;
}
}

36
templates/admin/transactions.html.twig

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
{% extends 'base.html.twig' %}
{% block title %}Credit Transactions{% endblock %}
{% block body %}
<h1>Credit Transactions</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>NPub</th>
<th>Type</th>
<th>Amount</th>
<th>Reason</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{% for tx in transactions %}
<tr>
<td>{{ tx.id }}</td>
<td><span title="{{ tx.npub }}">{{ tx.npub|shortenNpub }}</span></td>
<td>{{ tx.type }}</td>
<td>{{ tx.amount }}</td>
<td>{{ tx.reason ?: '—' }}</td>
<td>{{ tx.createdAt|date('Y-m-d H:i:s') }}</td>
</tr>
{% else %}
<tr>
<td colspan="6">No transactions found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

6
templates/components/GetCreditsComponent.html.twig

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<div {{ attributes }}>
<button data-action="live#action"
data-live-action-param="grantVoucher">
Get 5 Free Credits
</button>
</div>

18
templates/components/Header.html.twig

@ -2,21 +2,9 @@ @@ -2,21 +2,9 @@
<div class="header__logo"><h1 class="brand"><a href="/">newsroom</a></h1></div>
<div class="header__categories">
<ul>
<li {% if app.current_route is same as("world") %}class="active"{% endif %}>
<a href="/world">World</a>
</li>
<li {% if app.current_route is same as("business") %}class="active"{% endif %}>
<a href="/business">Business</a>
</li>
<li {% if app.current_route is same as("technology") %}class="active"{% endif %}>
<a href="/technology">Technology</a>
</li>
<li {% if app.current_route is same as("lifestyle") %}class="active"{% endif %}>
<a href="/lifestyle">Lifestyle</a>
</li>
<li {% if app.current_route is same as("art") %}class="active"{% endif %}>
<a href="/art">Art</a>
</li>
{% for category in cats %}
<li><twig:Molecules:CategoryLink coordinate="{{ category }}" /></li>
{% endfor %}
</ul>
</div>
</header>

27
templates/components/Molecules/Card.html.twig

@ -1,28 +1,27 @@ @@ -1,28 +1,27 @@
{% if article is defined %}
<{{ tag }} {{ attributes }}>
<div class="card">
<div class="metadata">
{% if category %}
<small>{{ category }}</small>
{% else %}
<p>by <twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></p>
<small>{{ article.createdAt|date('F j Y') }}</small>
{% endif %}
</div>
<a href="{{ path('article-slug', {slug: article.slug}) }}">
<div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %}
<img src="{{ article.image }}" alt="">
<img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" >
{% endif %}
</div>
<div class="card-body">
{# <small>{{ article.createdAt|date('F j') }}</small>#}
<h2 class="card-title">{{ article.title }}</h2>
<p class="lede">
{{ article.summary }}
</p>
</div>
</{{ tag }}>
{#<div class="card card-footer">#}
{# <a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></a>#}
{#</div>#}
{% endif %}
{% if user is defined %}
<{{ tag }} {{ attributes }}>
<div class="card-body">
<h3 class="card-title">{{ user.name }}</h3>
<p>{{ user.about }}</p>
</a>
<div class="card-footer"></div>
</div>
</{{ tag }}>
{% endif %}

4
templates/components/Molecules/CategoryLink.html.twig

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
<a {% if path('magazine-category', { 'slug' : slug }) in app.request.uri %}class="active"{% endif %}
href="{{ path('magazine-category', { 'slug' : slug }) }}">
{{ title }}
</a>

4
templates/components/Molecules/UserFromNpub.html.twig

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
{% if user %}
<twig:Atoms:NameOrNpub :author="user" />
<a href="{{ path('author-profile', { npub: npub })}}"><twig:Atoms:NameOrNpub :author="user" /></a>
{% else %}
<span>{{ npub }}</span>
<a href="{{ path('author-profile', { npub: npub })}}"><span>{{ npub|shortenNpub }}</span></a>
{% endif %}

2
templates/components/Organisms/CardList.html.twig

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<div {{ attributes }}>
{% for item in list %}
{% if item.slug is not empty %}
<twig:Molecules:Card class="card" :article="item" tag="a" href="{{ path('article-slug', {slug: item.slug}) }}" ></twig:Molecules:Card>
<twig:Molecules:Card :article="item"></twig:Molecules:Card>
{% endif %}
{% endfor %}
</div>

7
templates/components/Organisms/Comments.html.twig

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
<div class="comments">
{% for item in list %}
<div class="comment">
<span>{{ item.content }}</span>
</div>
{% endfor %}
</div>

43
templates/components/Organisms/FeaturedList.html.twig

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
<div>
{% if list %}
<div class="featured-cat hidden">
<small><b>{{ title }}</b></small>
</div>
<div {{ attributes }}>
<div>
{% set feature = list[0] %}
<div class="card">
<a href="{{ path('article-slug', {slug: feature.slug}) }}">
<div class="card-header">
{% if feature.image %}
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}">
{% endif %}
</div>
<div class="card-body">
<h2 class="card-title">{{ feature.title }}</h2>
<p class="lede truncate">
{{ feature.summary }}
</p>
</div>
</a>
</div>
</div>
<div>
{% for item in list %}
{% if item != feature %}
<div class="card">
<a href="{{ path('article-slug', {slug: item.slug}) }}">
<div class="card-body">
<h2 class="card-title">{{ item.title }}</h2>
<p class="lede truncate">
{{ item.summary }}
</p>
</div>
</a>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>

22
templates/components/SearchComponent.html.twig

@ -1,19 +1,26 @@ @@ -1,19 +1,26 @@
<div {{ attributes }}>
{% if interactive %}
<form data-live-action-param="search"
data-action="live#action:prevent">
<label class="search">
<input type="search"
placeholder="{{ 'text.search'|trans }}"
data-model="norender|query"
/>
<button type="submit" data-action="live#$render"><twig:ux:icon name="iconoir:search" class="icon" /></button>
<button type="submit"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>
<!--
<div style="text-align: right">
<small class="help-text"><em>Powered by Silk</em></small>
</div> -->
<small class="help-text">
<em>{{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }}</em>
</small>
</div>
</form>
<!-- Loading Indicator -->
<div style="text-align: center">
<div class="spinner" data-loading>
<div class="lds-dual-ring"></div>
</div>
<span data-loading>{{ 'text.searching'|trans }}</span>
</div>
{% endif %}
@ -24,5 +31,10 @@ @@ -24,5 +31,10 @@
{% elseif this.query is not empty %}
<p><small>{{ 'text.noResults'|trans }}</small></p>
{% endif %}
</div>
{% block aside %}
{% if credits == 0 %}
<twig:GetCreditsComponent />
{% endif %}
{% endblock %}
</div>

6
templates/home.html.twig

@ -5,9 +5,9 @@ @@ -5,9 +5,9 @@
{% block body %}
{# content #}
{# replace list with featured #}
<twig:Organisms:CardList :list="list" class="article-list"/>
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
{% endblock %}
{% block aside %}

8
templates/pages/article.html.twig

@ -16,7 +16,9 @@ @@ -16,7 +16,9 @@
{% if article.publishedAt is not null %}
<small>{{ article.publishedAt|date('F j, Y') }}</small>
{% else %}
<small><twig:ux:icon name="heroicons:pencil" class="icon" /> {{ article.createdAt|date('F j, Y') }}</small><br>
<small>
{# <twig:ux:icon name="heroicons:pencil" class="icon" /> #}
{{ article.createdAt|date('F j, Y') }}</small><br>
{% endif %}
</span>
</div>
@ -49,9 +51,13 @@ @@ -49,9 +51,13 @@
{# <pre>#}
{# {{ article.content }}#}
{# </pre>#}
<twig:Organisms:Comments current="30032:{{ article.pubkey }}:{{ article.slug }}"></twig:Organisms:Comments>
{% endblock %}
{% block aside %}
{% if is_granted('ROLE_ADMIN') %}
<p>30032:{{ article.pubkey }}:{{ article.slug }}</p>
{% endif %}
{# <h1>Suggestions</h1>#}
{# <twig:Organisms:CardList :list="suggestions" />#}
{% endblock %}

62
templates/pages/author.html.twig

@ -13,37 +13,37 @@ @@ -13,37 +13,37 @@
</p>
{% endif %}
{% if app.user and app.user.userIdentifier is same as npub %}
<div id="invoice-component" class="p-4 bg-gray-100 rounded-lg">
<h3 class="text-xl font-semibold mb-4">Purchase Search Credits</h3>
<div class="mb-4">
<p>Amount: {{ amount ?? 0 }} sats</p>
<p>Status: <span id="payment-status">Pending</span></p>
</div>
{# You can access the width and height via the matrix #}
{# Replace the string with the invoice #}
{% set qrCode = qr_code_result('My QR Code') %}
<img src="{{ qrCode.dataUri }}" width="{{ qrCode.matrix.outerSize }}" alt="invoice-qr" />
<br>
<button id="check-payment" class="px-4 py-2 bg-blue-500 text-white rounded">Check Payment Status</button>
<script>
document.getElementById('check-payment').addEventListener('click', async () => {
const response = await fetch('/payment-status/{{ payment_hash ?? '' }}');
const data = await response.json();
if (data.status === 'paid') {
document.getElementById('payment-status').innerText = 'Paid';
} else {
document.getElementById('payment-status').innerText = 'Pending';
}
});
</script>
</div>
{% endif %}
{# {% if app.user and app.user.userIdentifier is same as npub %}#}
{# <div id="invoice-component" class="p-4 bg-gray-100 rounded-lg">#}
{# <h3 class="text-xl font-semibold mb-4">Purchase Search Credits</h3>#}
{# <div class="mb-4">#}
{# <p>Amount: {{ amount ?? 0 }} sats</p>#}
{# <p>Status: <span id="payment-status">Pending</span></p>#}
{# </div>#}
{# #}{# You can access the width and height via the matrix #}
{# #}{# Replace the string with the invoice #}
{# {% set qrCode = qr_code_result('My QR Code') %}#}
{# <img src="{{ qrCode.dataUri }}" width="{{ qrCode.matrix.outerSize }}" alt="invoice-qr" />#}
{# <br>#}
{# <button id="check-payment" class="px-4 py-2 bg-blue-500 text-white rounded">Check Payment Status</button>#}
{# <script>#}
{# document.getElementById('check-payment').addEventListener('click', async () => {#}
{# const response = await fetch('/payment-status/{{ payment_hash ?? '' }}');#}
{# const data = await response.json();#}
{# if (data.status === 'paid') {#}
{# document.getElementById('payment-status').innerText = 'Paid';#}
{# } else {#}
{# document.getElementById('payment-status').innerText = 'Pending';#}
{# }#}
{# });#}
{# </script>#}
{# </div>#}
{# {% endif %}#}
{% if nzine %}
<a href="{{ path('nzine_view', {npub: author.npub}) }}">View as N-Zine</a>

5
templates/pages/search.html.twig

@ -6,8 +6,3 @@ @@ -6,8 +6,3 @@
{% block body %}
<twig:SearchComponent />
{% endblock %}
{% block aside %}
{# <h6>Magazines</h6>#}
{# <twig:Organisms:ZineList />#}
{% endblock %}

3
templates/static/about.html.twig

@ -7,7 +7,8 @@ @@ -7,7 +7,8 @@
<h1>About Decent Newsroom</h1>
<h2>Rebuilding Trust in Journalism</h2>
<p>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.</p>
<p>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.</p>
<p>Decent Newsroom is our answer to this crisis. We are building a curated, decentralized magazine featuring high-quality, long-form journalism published on Nostr.</p>

14
templates/static/roadmap.html.twig

@ -5,13 +5,15 @@ @@ -5,13 +5,15 @@
{% block body %}
<h1>Roadmap: The Future of Decent Newsroom</h1>
<p>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.</p>
<p>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.</p>
<div class="roadmap-section">
<h2>v0.1.0 – <strong>Preprint</strong> <span class="badge badge__secondary"><small>Current</small></span></h2>
<div class="milestone">
<h3>A first glimpse into the future of journalism</h3>
<p>This early version of First Edition Newsroom is all about discovery. We’re launching a sample magazine to showcase what’s possible.</p>
<p>This early version of Decent Newsroom is all about discovery. We’re launching a sample magazine
to showcase what’s possible.</p>
<ul>
<li>Hand-curated, high-quality articles in magazine format</li>
<li>Full-text search to find the stories that matter to you</li>
@ -28,11 +30,11 @@ @@ -28,11 +30,11 @@
<p>This milestone version introduces key features that bring Decent Newsroom to life:</p>
<ul>
<li>Zaps</li>
<li>Search and indexing requests enabled – writers and publishers can request inclusion</li>
<li>Payable subscriptions and indexing via Lightning invoices</li>
<li>Built-in content editor publishers can create directly on the platform</li>
<li>Content submissions</li>
<li>Subscriptions for premium access</li>
<li>Built-in content editor so publishers can create directly on the platform</li>
</ul>
<p>With First Edition, journalism becomes more than just content it becomes an ecosystem.</p>
<p>With First Edition, journalism becomes more than just content, it becomes an ecosystem.</p>
<p></p>
</div>
</div>

3
translations/messages.en.yaml

@ -11,3 +11,6 @@ heading: @@ -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'

Loading…
Cancel
Save