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:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
environment: environment:
APP_ENCRYPTION_KEY: '%env(APP_ENCRYPTION_KEY)%' APP_ENV: ${APP_ENV:-dev}
SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!}
@ -55,6 +55,14 @@ services:
# - ./docker/db/data:/var/lib/postgresql/data:rw # - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
cron:
build:
context: ./docker/cron
volumes:
- .:/var/www/html
depends_on:
- php
volumes: volumes:
caddy_data: caddy_data:
caddy_config: caddy_config:

181
composer.lock generated

@ -506,26 +506,29 @@
}, },
{ {
"name": "doctrine/deprecations", "name": "doctrine/deprecations",
"version": "1.1.4", "version": "1.1.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/deprecations.git", "url": "https://github.com/doctrine/deprecations.git",
"reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
"reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.1 || ^8.0" "php": "^7.1 || ^8.0"
}, },
"conflict": {
"phpunit/phpunit": "<=7.5 || >=13"
},
"require-dev": { "require-dev": {
"doctrine/coding-standard": "^9 || ^12", "doctrine/coding-standard": "^9 || ^12 || ^13",
"phpstan/phpstan": "1.4.10 || 2.0.3", "phpstan/phpstan": "1.4.10 || 2.1.11",
"phpstan/phpstan-phpunit": "^1.0 || ^2", "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" "psr/log": "^1 || ^2 || ^3"
}, },
"suggest": { "suggest": {
@ -545,9 +548,9 @@
"homepage": "https://www.doctrine-project.org/", "homepage": "https://www.doctrine-project.org/",
"support": { "support": {
"issues": "https://github.com/doctrine/deprecations/issues", "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", "name": "doctrine/doctrine-bundle",
@ -1088,16 +1091,16 @@
}, },
{ {
"name": "doctrine/migrations", "name": "doctrine/migrations",
"version": "3.8.3", "version": "3.9.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/migrations.git", "url": "https://github.com/doctrine/migrations.git",
"reference": "6af8dffde46a67f2a60906b6a28973e5a3670405" "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/migrations/zipball/6af8dffde46a67f2a60906b6a28973e5a3670405", "url": "https://api.github.com/repos/doctrine/migrations/zipball/325b61e41d032f5f7d7e2d11cbefff656eadc9ab",
"reference": "6af8dffde46a67f2a60906b6a28973e5a3670405", "reference": "325b61e41d032f5f7d7e2d11cbefff656eadc9ab",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1117,7 +1120,7 @@
"require-dev": { "require-dev": {
"doctrine/coding-standard": "^12", "doctrine/coding-standard": "^12",
"doctrine/orm": "^2.13 || ^3", "doctrine/orm": "^2.13 || ^3",
"doctrine/persistence": "^2 || ^3", "doctrine/persistence": "^2 || ^3 || ^4",
"doctrine/sql-formatter": "^1.0", "doctrine/sql-formatter": "^1.0",
"ext-pdo_sqlite": "*", "ext-pdo_sqlite": "*",
"fig/log-test": "^1", "fig/log-test": "^1",
@ -1171,7 +1174,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/migrations/issues", "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": [ "funding": [
{ {
@ -1187,7 +1190,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-03-10T23:43:55+00:00" "time": "2025-03-26T06:48:45+00:00"
}, },
{ {
"name": "doctrine/orm", "name": "doctrine/orm",
@ -1558,16 +1561,16 @@
}, },
{ {
"name": "endroid/qr-code", "name": "endroid/qr-code",
"version": "6.0.6", "version": "6.0.7",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/endroid/qr-code.git", "url": "https://github.com/endroid/qr-code.git",
"reference": "11e6a94458dab8dd18736c11892130ec788b5028" "reference": "de0e510509abf854e2218e4c77ed62321b27ea97"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/endroid/qr-code/zipball/11e6a94458dab8dd18736c11892130ec788b5028", "url": "https://api.github.com/repos/endroid/qr-code/zipball/de0e510509abf854e2218e4c77ed62321b27ea97",
"reference": "11e6a94458dab8dd18736c11892130ec788b5028", "reference": "de0e510509abf854e2218e4c77ed62321b27ea97",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1618,7 +1621,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/endroid/qr-code/issues", "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": [ "funding": [
{ {
@ -1626,7 +1629,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-03-14T23:29:08+00:00" "time": "2025-04-02T07:06:35+00:00"
}, },
{ {
"name": "endroid/qr-code-bundle", "name": "endroid/qr-code-bundle",
@ -2065,16 +2068,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.6.1", "version": "2.6.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "d990688c91cedfb69753ffc2512727ec646df2ad" "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/06c3b0bf2540338094575612f4a1778d0d2d5e94",
"reference": "d990688c91cedfb69753ffc2512727ec646df2ad", "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2168,7 +2171,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-12-29T14:10:59+00:00" "time": "2025-04-18T21:09:27+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@ -2646,16 +2649,16 @@
}, },
{ {
"name": "nette/utils", "name": "nette/utils",
"version": "v4.0.5", "version": "v4.0.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nette/utils.git", "url": "https://github.com/nette/utils.git",
"reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96" "reference": "ce708655043c7050eb050df361c5e313cf708309"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", "url": "https://api.github.com/repos/nette/utils/zipball/ce708655043c7050eb050df361c5e313cf708309",
"reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", "reference": "ce708655043c7050eb050df361c5e313cf708309",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2726,9 +2729,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nette/utils/issues", "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", "name": "nyholm/dsn",
@ -3126,16 +3129,16 @@
}, },
{ {
"name": "phpdocumentor/reflection-docblock", "name": "phpdocumentor/reflection-docblock",
"version": "5.6.1", "version": "5.6.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
"reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/92dde6a5919e34835c506ac8c523ef095a95ed62",
"reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", "reference": "92dde6a5919e34835c506ac8c523ef095a95ed62",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3184,9 +3187,9 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": { "support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", "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", "name": "phpdocumentor/type-resolver",
@ -3467,16 +3470,16 @@
}, },
{ {
"name": "phrity/websocket", "name": "phrity/websocket",
"version": "3.3.0", "version": "3.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sirn-se/websocket-php.git", "url": "https://github.com/sirn-se/websocket-php.git",
"reference": "73132b31f87b5f673ed492d9d4a4794cbbafe05c" "reference": "716f96d7a36daf654a8fe9eb400ed9609c0882ca"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sirn-se/websocket-php/zipball/73132b31f87b5f673ed492d9d4a4794cbbafe05c", "url": "https://api.github.com/repos/sirn-se/websocket-php/zipball/716f96d7a36daf654a8fe9eb400ed9609c0882ca",
"reference": "73132b31f87b5f673ed492d9d4a4794cbbafe05c", "reference": "716f96d7a36daf654a8fe9eb400ed9609c0882ca",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3523,9 +3526,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/sirn-se/websocket-php/issues", "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", "name": "psr/cache",
@ -4284,16 +4287,16 @@
}, },
{ {
"name": "swentel/nostr-php", "name": "swentel/nostr-php",
"version": "1.6.0", "version": "1.7.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nostrver-se/nostr-php.git", "url": "https://github.com/nostrver-se/nostr-php.git",
"reference": "541d9d074f942522425399efe0a77c80e44a1e21" "reference": "44f17cad00dec8d3b5a35c3096a7ec83d8c683fa"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/541d9d074f942522425399efe0a77c80e44a1e21", "url": "https://api.github.com/repos/nostrver-se/nostr-php/zipball/44f17cad00dec8d3b5a35c3096a7ec83d8c683fa",
"reference": "541d9d074f942522425399efe0a77c80e44a1e21", "reference": "44f17cad00dec8d3b5a35c3096a7ec83d8c683fa",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -4346,9 +4349,9 @@
"chat": "https://t.me/nostr_php", "chat": "https://t.me/nostr_php",
"issue": "https://github.com/swentel/nostr-php/issues", "issue": "https://github.com/swentel/nostr-php/issues",
"issues": "https://github.com/nostrver-se/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", "name": "symfony/asset",
@ -8068,16 +8071,16 @@
}, },
{ {
"name": "symfony/stimulus-bundle", "name": "symfony/stimulus-bundle",
"version": "v2.23.0", "version": "v2.24.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/stimulus-bundle.git", "url": "https://github.com/symfony/stimulus-bundle.git",
"reference": "254f4e05cbaa349d4ae68b9b2e6a22995e0887f9" "reference": "e09840304467cda3324cc116c7f4ee23c8ff227c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/254f4e05cbaa349d4ae68b9b2e6a22995e0887f9", "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/e09840304467cda3324cc116c7f4ee23c8ff227c",
"reference": "254f4e05cbaa349d4ae68b9b2e6a22995e0887f9", "reference": "e09840304467cda3324cc116c7f4ee23c8ff227c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8117,7 +8120,7 @@
"symfony-ux" "symfony-ux"
], ],
"support": { "support": {
"source": "https://github.com/symfony/stimulus-bundle/tree/v2.23.0" "source": "https://github.com/symfony/stimulus-bundle/tree/v2.24.0"
}, },
"funding": [ "funding": [
{ {
@ -8133,7 +8136,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-01-16T21:55:09+00:00" "time": "2025-03-09T21:10:04+00:00"
}, },
{ {
"name": "symfony/stopwatch", "name": "symfony/stopwatch",
@ -8733,16 +8736,16 @@
}, },
{ {
"name": "symfony/ux-icons", "name": "symfony/ux-icons",
"version": "v2.23.0", "version": "v2.24.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/ux-icons.git", "url": "https://github.com/symfony/ux-icons.git",
"reference": "6aa0b14dededcd65c515c445aedfda318fa61f9e" "reference": "39f689b41081f7788ee9c4a188817599d546bfb2"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/ux-icons/zipball/6aa0b14dededcd65c515c445aedfda318fa61f9e", "url": "https://api.github.com/repos/symfony/ux-icons/zipball/39f689b41081f7788ee9c4a188817599d546bfb2",
"reference": "6aa0b14dededcd65c515c445aedfda318fa61f9e", "reference": "39f689b41081f7788ee9c4a188817599d546bfb2",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8802,7 +8805,7 @@
"twig" "twig"
], ],
"support": { "support": {
"source": "https://github.com/symfony/ux-icons/tree/v2.23.0" "source": "https://github.com/symfony/ux-icons/tree/v2.24.0"
}, },
"funding": [ "funding": [
{ {
@ -8818,26 +8821,27 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-12-26T07:58:05+00:00" "time": "2025-04-04T17:32:18+00:00"
}, },
{ {
"name": "symfony/ux-live-component", "name": "symfony/ux-live-component",
"version": "v2.23.0", "version": "v2.24.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/ux-live-component.git", "url": "https://github.com/symfony/ux-live-component.git",
"reference": "840542868a8473b49036ec1ed0c5238d14b075a8" "reference": "ee1a8e5d01f5b3f2f8e6856941fa8c944677e41c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/ux-live-component/zipball/840542868a8473b49036ec1ed0c5238d14b075a8", "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/ee1a8e5d01f5b3f2f8e6856941fa8c944677e41c",
"reference": "840542868a8473b49036ec1ed0c5238d14b075a8", "reference": "ee1a8e5d01f5b3f2f8e6856941fa8c944677e41c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.1", "php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3.0", "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/property-access": "^5.4.5|^6.0|^7.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/stimulus-bundle": "^2.9",
"symfony/ux-twig-component": "^2.8", "symfony/ux-twig-component": "^2.8",
"twig/twig": "^3.8.0" "twig/twig": "^3.8.0"
@ -8858,7 +8862,6 @@
"symfony/framework-bundle": "^5.4|^6.0|^7.0", "symfony/framework-bundle": "^5.4|^6.0|^7.0",
"symfony/options-resolver": "^5.4|^6.0|^7.0", "symfony/options-resolver": "^5.4|^6.0|^7.0",
"symfony/phpunit-bridge": "^6.1|^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/security-bundle": "^5.4|^6.0|^7.0",
"symfony/serializer": "^5.4|^6.0|^7.0", "symfony/serializer": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0", "symfony/twig-bundle": "^5.4|^6.0|^7.0",
@ -8896,7 +8899,7 @@
"twig" "twig"
], ],
"support": { "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": [ "funding": [
{ {
@ -8912,20 +8915,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-02-07T23:57:34+00:00" "time": "2025-03-12T08:41:47+00:00"
}, },
{ {
"name": "symfony/ux-twig-component", "name": "symfony/ux-twig-component",
"version": "v2.23.0", "version": "v2.24.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/ux-twig-component.git", "url": "https://github.com/symfony/ux-twig-component.git",
"reference": "f29033b95e93aea2d498dc40eac185ed14b07800" "reference": "48a46e4c6215d41cc97ba8dff0cff21ea9b255a8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/f29033b95e93aea2d498dc40eac185ed14b07800", "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/48a46e4c6215d41cc97ba8dff0cff21ea9b255a8",
"reference": "f29033b95e93aea2d498dc40eac185ed14b07800", "reference": "48a46e4c6215d41cc97ba8dff0cff21ea9b255a8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8979,7 +8982,7 @@
"twig" "twig"
], ],
"support": { "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": [ "funding": [
{ {
@ -8995,7 +8998,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-01-25T02:19:26+00:00" "time": "2025-03-21T20:14:36+00:00"
}, },
{ {
"name": "symfony/var-dumper", "name": "symfony/var-dumper",
@ -9612,16 +9615,16 @@
"packages-dev": [ "packages-dev": [
{ {
"name": "myclabs/deep-copy", "name": "myclabs/deep-copy",
"version": "1.13.0", "version": "1.13.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/myclabs/DeepCopy.git", "url": "https://github.com/myclabs/DeepCopy.git",
"reference": "024473a478be9df5fdaca2c793f2232fe788e414" "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c",
"reference": "024473a478be9df5fdaca2c793f2232fe788e414", "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -9660,7 +9663,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/myclabs/DeepCopy/issues", "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": [ "funding": [
{ {
@ -9668,7 +9671,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-02-12T12:17:51+00:00" "time": "2025-04-29T12:36:36+00:00"
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
@ -11433,21 +11436,21 @@
}, },
{ {
"name": "symfony/maker-bundle", "name": "symfony/maker-bundle",
"version": "v1.62.1", "version": "v1.63.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/maker-bundle.git", "url": "https://github.com/symfony/maker-bundle.git",
"reference": "468ff2708200c95ebc0d85d3174b6c6711b8a590" "reference": "69478ab39bc303abfbe3293006a78b09a8512425"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/maker-bundle/zipball/468ff2708200c95ebc0d85d3174b6c6711b8a590", "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/69478ab39bc303abfbe3293006a78b09a8512425",
"reference": "468ff2708200c95ebc0d85d3174b6c6711b8a590", "reference": "69478ab39bc303abfbe3293006a78b09a8512425",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"doctrine/inflector": "^2.0", "doctrine/inflector": "^2.0",
"nikic/php-parser": "^4.18|^5.0", "nikic/php-parser": "^5.0",
"php": ">=8.1", "php": ">=8.1",
"symfony/config": "^6.4|^7.0", "symfony/config": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0", "symfony/console": "^6.4|^7.0",
@ -11505,7 +11508,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/symfony/maker-bundle/issues", "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": [ "funding": [
{ {
@ -11521,7 +11524,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-01-15T00:21:40+00:00" "time": "2025-04-26T01:41:37+00:00"
}, },
{ {
"name": "symfony/phpunit-bridge", "name": "symfony/phpunit-bridge",

1
config/packages/cache.yaml

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

6
config/packages/doctrine.yaml

@ -24,6 +24,12 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity' dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity' prefix: 'App\Entity'
alias: App alias: App
Credits:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Credits/Entity'
prefix: 'App\Credits\Entity'
alias: Credits
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false

4
config/services.yaml

@ -66,3 +66,7 @@ services:
App\EventListener\PopulateListener: App\EventListener\PopulateListener:
tags: tags:
- { name: kernel.event_listener, event: 'FOS\ElasticaBundle\Event\PostIndexPopulateEvent', method: 'postIndexPopulate' } - { 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 @@
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 @@
# 🕒 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 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1

8
docker/cron/index_articles.sh

@ -0,0 +1,8 @@
#!/bin/bash
set -e
# Run Symfony commands sequentially
/usr/local/bin/php /var/www/html/bin/console articles:get
/usr/local/bin/php /var/www/html/bin/console articles:qa
/usr/local/bin/php /var/www/html/bin/console articles:index
/usr/local/bin/php /var/www/html/bin/console articles:indexed

38
migrations/Version20250509100039.php

@ -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 @@
<?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 @@
<?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 @@
<?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 @@
<?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 @@
<?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 @@
<?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 @@
<?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 @@
<?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 @@
<?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;
use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Exception\CommonMarkException;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Workflow\WorkflowInterface; use Symfony\Component\Workflow\WorkflowInterface;
class ArticleController extends AbstractController class ArticleController extends AbstractController
{ {
/** /**
* @throws InvalidArgumentException|CommonMarkException
* @throws \Exception * @throws \Exception
*/ */
#[Route('/article/{naddr}', name: 'article-naddr')] #[Route('/article/{naddr}', name: 'article-naddr')]
@ -64,7 +65,12 @@ class ArticleController extends AbstractController
throw new \Exception('Not a long form article'); 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) { if ($slug) {
return $this->redirectToRoute('article-slug', ['slug' => $slug]); return $this->redirectToRoute('article-slug', ['slug' => $slug]);
@ -86,6 +92,10 @@ class ArticleController extends AbstractController
$articles = $repository->findBy(['slug' => $slug]); $articles = $repository->findBy(['slug' => $slug]);
$revisions = count($articles); $revisions = count($articles);
if ($revisions === 0) {
throw $this->createNotFoundException('The article could not be found');
}
if ($revisions > 1) { if ($revisions > 1) {
// sort articles by created at date // sort articles by created at date
usort($articles, function ($a, $b) { usort($articles, function ($a, $b) {
@ -97,16 +107,12 @@ class ArticleController extends AbstractController
$article = $articles[0]; $article = $articles[0];
} }
if (!$article) {
throw $this->createNotFoundException('The article does not exist');
}
$cacheKey = 'article_' . $article->getId(); $cacheKey = 'article_' . $article->getId();
$cacheItem = $articlesCache->getItem($cacheKey); $cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) { // if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent())); $cacheItem->set($converter->convertToHtml($article->getContent()));
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} //}
// // suggestions // // suggestions
// $suggestions = $repository->findBy(['pubkey' => $article->getPubkey()], ['createdAt' => 'DESC'], 3); // $suggestions = $repository->findBy(['pubkey' => $article->getPubkey()], ['createdAt' => 'DESC'], 3);
@ -120,6 +126,7 @@ class ArticleController extends AbstractController
// return $b->getCreatedAt() <=> $a->getCreatedAt(); // return $b->getCreatedAt() <=> $a->getCreatedAt();
// }); // });
try {
$meta = $nostrClient->getNpubMetadata($article->getPubkey()); $meta = $nostrClient->getNpubMetadata($article->getPubkey());
if ($meta?->content) { if ($meta?->content) {
$author = (array) json_decode($meta->content); $author = (array) json_decode($meta->content);
@ -128,10 +135,14 @@ class ArticleController extends AbstractController
'name' => '<anonymous>' 'name' => '<anonymous>'
]; ];
} }
} catch (\Exception $e) {
// Whatever?
}
return $this->render('Pages/article.html.twig', [ return $this->render('Pages/article.html.twig', [
'article' => $article, 'article' => $article,
'author' => $author, 'author' => $author ?? null,
'content' => $cacheItem->get(), 'content' => $cacheItem->get(),
//'suggestions' => $suggestions //'suggestions' => $suggestions
]); ]);
@ -140,10 +151,13 @@ class ArticleController extends AbstractController
/** /**
* Create new article * Create new article
* @throws InvalidArgumentException
* @throws \Exception
*/ */
#[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/create', name: 'editor-create')]
#[Route('/article-editor/edit/{id}', name: 'editor-edit')] #[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) { if (!$article) {
$article = new Article(); $article = new Article();
@ -160,24 +174,33 @@ class ArticleController extends AbstractController
// Step 3: Check if the form is submitted and valid // Step 3: Check if the form is submitted and valid
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser(); $user = $this->getUser();
$currentPubkey = $user->getUserIdentifier(); $key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier());
if ($article->getPubkey() === null) { if ($article->getPubkey() === null) {
$article->setPubkey($currentPubkey); $article->setPubkey($currentPubkey);
} }
// Check which button was clicked // 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 // Save button was clicked, handle the "Publish" action
$this->addFlash('success', 'Product published!'); $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 // Save and Publish button was clicked, handle the "Draft" action
$this->addFlash('success', 'Product saved as draft!'); $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 // 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 $article->setSig(''); // clear the sig
$entityManager->persist($article); $article->setSlug($slug);
$entityManager->flush(); $cacheKey = 'article_' . $currentPubkey . '_' . $article->getSlug();
return $this->redirectToRoute('article-preview', ['id' => $article->getId()]); $cacheItem = $articlesCache->getItem($cacheKey);
$cacheItem->set($article);
$articlesCache->save($cacheItem);
return $this->redirectToRoute('article-preview', ['d' => $article->getSlug()]);
} }
} }
@ -190,16 +213,28 @@ class ArticleController extends AbstractController
/** /**
* Preview article * Preview article
* @throws InvalidArgumentException
* @throws CommonMarkException
* @throws \Exception
*/ */
#[Route('/article-preview/{id}', name: 'article-preview')] #[Route('/article-preview/{d}', name: 'article-preview')]
public function preview($id, EntityManagerInterface $entityManager): Response public function preview($d, Converter $converter,
CacheItemPoolInterface $articlesCache): Response
{ {
$repository = $entityManager->getRepository(Article::class); $user = $this->getUser();
$article = $repository->findOneBy(['id' => $id]); $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', [ return $this->render('pages/article.html.twig', [
'article' => $article, 'article' => $article,
'author' => $this->getUser(), 'content' => $content,
'author' => $user->getMetadata(),
]); ]);
} }

14
src/Controller/AuthorController.php

@ -25,15 +25,13 @@ class AuthorController extends AbstractController
public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response public function index($npub, EntityManagerInterface $entityManager, NostrClient $client): Response
{ {
$keys = new Key(); $keys = new Key();
$pubkey = $keys->convertToHex($npub);
$meta = $client->getNpubMetadata($npub); $meta = $client->getNpubMetadata($pubkey);
$author = (array) json_decode($meta->content ?? '{}');
// $client->getNpubLongForm($npub);
$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 // deduplicate by slugs
$articles = []; $articles = [];
@ -43,7 +41,7 @@ class AuthorController extends AbstractController
} }
} }
$indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]); // $indices = $entityManager->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// $nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $pubkey]); // $nzines = $entityManager->getRepository(Nzine::class)->findBy(['editor' => $pubkey]);
@ -55,7 +53,7 @@ class AuthorController extends AbstractController
'articles' => $articles, 'articles' => $articles,
'nzine' => null, 'nzine' => null,
'nzines' => null, 'nzines' => null,
'idx' => $indices 'idx' => null
]); ]);
} }

62
src/Controller/DefaultController.php

@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use Elastica\Query\MatchQuery;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Finder\FinderInterface; use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Event\Event;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; 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\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
@ -19,30 +18,59 @@ class DefaultController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly FinderInterface $esFinder, private readonly FinderInterface $esFinder,
private readonly EntityManagerInterface $entityManager) private readonly CacheInterface $redisCache)
{ {
} }
/** /**
* @throws \Exception * @throws \Exception
* @throws InvalidArgumentException
*/ */
#[Route('/', name: 'default')] #[Route('/', name: 'home')]
public function index(): Response 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 $cats = array_filter($tags, function($tag) {
$deduplicated = []; return ($tag[0] === 'a');
foreach ($list as $item) { });
if (!key_exists((string) $item->getSlug(), $deduplicated)) {
$deduplicated[(string) $item->getSlug()] = $item; 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 $this->render('pages/category.html.twig', [
return !empty($item->getImage()); 'list' => array_slice($articles, 0, 9)
}))
]); ]);
} }

100
src/Credits/Entity/CreditTransaction.php

@ -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 @@
<?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 @@
<?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
$entity->setRaw($source); $entity->setRaw($source);
$entity->setEventId($source->id); $entity->setEventId($source->id);
$entity->setCreatedAt(\DateTimeImmutable::createFromFormat('U', (string)$source->created_at)); $entity->setCreatedAt(\DateTimeImmutable::createFromFormat('U', (string)$source->created_at));
// TODO escape content before saving
$entity->setContent($source->content); $entity->setContent($source->content);
$entity->setKind(KindsEnum::from($source->kind)); $entity->setKind(KindsEnum::from($source->kind));
$entity->setPubkey($source->pubkey); $entity->setPubkey($source->pubkey);
@ -46,6 +45,7 @@ class ArticleFactory
break; break;
case 'published_at': case 'published_at':
$entity->setPublishedAt(\DateTimeImmutable::createFromFormat('U', (string)$tag[1])); $entity->setPublishedAt(\DateTimeImmutable::createFromFormat('U', (string)$tag[1]));
break;
case 't': case 't':
$entity->addTopic($tag[1]); $entity->addTopic($tag[1]);
break; break;

19
src/Security/UserDTOProvider.php

@ -3,8 +3,10 @@
namespace App\Security; namespace App\Security;
use App\Entity\User; use App\Entity\User;
use App\Enum\KindsEnum;
use App\Service\NostrClient; use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use swentel\nostr\Key\Key;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Core\User\UserProviderInterface;
@ -52,22 +54,27 @@ readonly class UserDTOProvider implements UserProviderInterface
} }
try { try {
$data = $this->nostrClient->getLoginData($identifier); $key = new Key();
$pubkey = $key->convertToHex($identifier);
$data = $this->nostrClient->getLoginData($pubkey);
foreach ($data as $d) { foreach ($data as $d) {
$ev = $d->event; $ev = $d->event;
if ($ev->kind === 0) { if ($ev->kind === KindsEnum::METADATA) {
$metadata = json_decode($ev->content); $metadata = json_decode($ev->content);
} }
if ($ev->kind === 10002) { if ($ev->kind === KindsEnum::RELAY_LIST) {
$relays = $ev->tags; $relays = $ev->tags;
} }
} }
} catch (\Exception) { } catch (\Exception $e) {
// even if the user metadata not found, if sig is valid, login the pubkey // nothing to do here right now
}
if (is_null($metadata)) {
// if no metadata event, use what you have
$metadata = new \stdClass(); $metadata = new \stdClass();
$metadata->name = substr($identifier, 0, 5) . ':' . substr($identifier, -5); $metadata->name = substr($identifier, 0, 5) . ':' . substr($identifier, -5);
} }
$user->setMetadata($metadata); $user->setMetadata($metadata);
$user->setRelays($relays); $user->setRelays($relays);

256
src/Service/NostrClient.php

@ -9,7 +9,6 @@ use App\Factory\ArticleFactory;
use App\Repository\UserEntityRepository; use App\Repository\UserEntityRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
@ -21,9 +20,11 @@ use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription; use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; 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\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class NostrClient class NostrClient
{ {
@ -39,8 +40,12 @@ class NostrClient
{ {
// TODO configure read and write relays for logged in users from their 10002 events // TODO configure read and write relays for logged in users from their 10002 events
$this->defaultRelaySet = new RelaySet(); $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://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) public function getLoginData($npub)
@ -51,10 +56,7 @@ class NostrClient
$filter->setKinds([KindsEnum::METADATA, KindsEnum::RELAY_LIST]); $filter->setKinds([KindsEnum::METADATA, KindsEnum::RELAY_LIST]);
$filter->setAuthors([$npub]); $filter->setAuthors([$npub]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
// use default aggregator relay $request = new Request($this->defaultRelaySet, $requestMessage);
$relay = new Relay('wss://purplepag.es');
$request = new Request($relay, $requestMessage);
$response = $request->send(); $response = $request->send();
// response is an n-dimensional array, where n is the number of relays in the set // response is an n-dimensional array, where n is the number of relays in the set
@ -73,47 +75,55 @@ class NostrClient
/** /**
* @throws \Exception * @throws \Exception
* @throws InvalidArgumentException
*/ */
public function getNpubMetadata($npub) 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 = new Filter();
$filter->setKinds([KindsEnum::METADATA]); $filter->setKinds([KindsEnum::METADATA]);
$filter->setAuthors([$npub]); $filter->setAuthors([$npub]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $filters = [$filter];
$relays = new RelaySet(); $subscription = new Subscription();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator $requestMessage = new RequestMessage($subscription->getId(), $filters);
$relays->addRelay(new Relay('wss://relay.damus.io')); // major public $relays = [
$relays->addRelay(new Relay('wss://relay.snort.social')); // major public new Relay('wss://purplepag.es'),
new Relay('wss://theforest.nostr1.com'),
$request = new Request($relays, $requestMessage); ];
$relaySet = new RelaySet();
$relaySet->setRelays($relays);
$request = new Request($relaySet, $requestMessage);
$response = $request->send(); $response = $request->send();
$meta = [];
// response is an array of arrays // response is an array of arrays
foreach ($response as $value) { foreach ($response as $value) {
foreach ($value as $item) { foreach ($value as $item) {
switch ($item->type) { switch ($item->type) {
case 'EVENT': case 'EVENT':
return $item->event; $meta[] = $item->event;
break;
case 'AUTH': case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication'); throw new UnauthorizedHttpException('', 'Relay requires authentication');
case 'ERROR': case 'ERROR':
case 'NOTICE': case 'NOTICE':
throw new \Exception('An error occurred'); throw new \Exception('An error occurred');
default: 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 public function getNpubLongForm($npub): void
{ {
@ -132,9 +142,11 @@ class NostrClient
if ($user && $user->getRelays()) { if ($user && $user->getRelays()) {
$relays = new RelaySet(); $relays = new RelaySet();
foreach ($user->getRelays() as $relayArr) { foreach ($user->getRelays() as $relayArr) {
if ($relayArr[2] == 'write') {
$relays->addRelay(new Relay($relayArr[1])); $relays->addRelay(new Relay($relayArr[1]));
} }
} }
}
$request = new Request($relays, $requestMessage); $request = new Request($relays, $requestMessage);
@ -184,8 +196,7 @@ class NostrClient
$user = $this->tokenStorage->getToken()?->getUser(); $user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet; $relays = $this->defaultRelaySet;
if ($user) { if ($user) {
$relays = new RelaySet(); //$relays = new RelaySet();
} }
$request = new Request($relays, $requestMessage); $request = new Request($relays, $requestMessage);
@ -217,16 +228,21 @@ class NostrClient
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
if (empty($relayList)) {
$relays = $this->defaultRelaySet; $relays = $this->defaultRelaySet;
} else { if (!empty($relayList)) {
$relays = new RelaySet(); // $relays->addRelay(new Relay($relayList[0]));
$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 = $request->send();
}
// response is an n-dimensional array, where n is the number of relays in the set // response is an n-dimensional array, where n is the number of relays in the set
// check that response has events in the results // check that response has events in the results
foreach ($response as $relayRes) { foreach ($response as $relayRes) {
@ -282,28 +298,89 @@ class NostrClient
} }
public function getProfileEvents($npub): void /**
* Save user metadata
*/
private function saveMetadata($metadata): void
{
try {
$user = $this->serializer->deserialize($metadata->content, User::class, 'json');
$user->setNpub($metadata->pubkey);
} catch (\Exception $e) {
$this->logger->error('Deserialization of user data failed.', ['exception' => $e]);
return;
}
try {
$this->logger->info('Saving user', ['user' => $user]);
$this->userEntityRepository->findOrCreateByUniqueField($user);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
private function saveLongFormContent(mixed $filtered): void
{
foreach ($filtered as $wrapper) {
$article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event);
// check if event with same eventId already in DB
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]);
if (!$saved) {
try {
$this->logger->info('Saving article', ['article' => $article]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
$this->managerRegistry->resetManager();
}
}
}
}
/**
*
* @return array
*/
public function getNpubRelays($pubkey)
{ {
$subscription = new Subscription(); $subscription = new Subscription();
$subscriptionId = $subscription->setId(); $subscriptionId = $subscription->setId();
$filter = new Filter(); $filter = new Filter();
$filter->setKinds([KindsEnum::METADATA, KindsEnum::FOLLOWS, KindsEnum::RELAY_LIST]); $filter->setKinds([KindsEnum::RELAY_LIST]);
$filter->setAuthors([$npub]); $filter->setAuthors([$pubkey]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
// TODO make relays configurable // TODO make relays configurable
$relays = new RelaySet(); $relays = new RelaySet();
$relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator $relays->addRelay(new Relay('wss://purplepag.es')); // default metadata aggregator
$relays->addRelay(new Relay('wss://nos.lol')); // default public $relays->addRelay(new Relay('wss://nos.lol')); // default public
$relays->addRelay(new Relay('wss://theforest.nostr1.com')); // default public
$request = new Request($relays, $requestMessage); $request = new Request($relays, $requestMessage);
$response = $request->send(); $response = $request->send();
// response is an array of arrays // response is an array of arrays
foreach ($response as $value) { foreach ($response as $value) {
foreach ($value as $item) { foreach ($value as $item) {
switch ($item->type) { switch ($item->type) {
case 'EVENT': 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; break;
case 'AUTH': case 'AUTH':
throw new UnauthorizedHttpException('', 'Relay requires authentication'); throw new UnauthorizedHttpException('', 'Relay requires authentication');
@ -315,48 +392,97 @@ class NostrClient
} }
} }
} }
return [];
} }
/** /**
* Save user metadata * @throws \Exception
*/ */
private function saveMetadata($metadata): void public function getComments($coordinate): array
{ {
try { $list = [];
$user = $this->serializer->deserialize($metadata->content, User::class, 'json'); $parts = explode(':', $coordinate);
$user->setNpub($metadata->pubkey);
} catch (\Exception $e) {
$this->logger->error('Deserialization of user data failed.', ['exception' => $e]);
return;
}
try { $subscription = new Subscription();
$this->logger->info('Saving user', ['user' => $user]); $subscriptionId = $subscription->setId();
$this->userEntityRepository->findOrCreateByUniqueField($user); $filter = new Filter();
$this->entityManager->flush(); $filter->setKinds([KindsEnum::COMMENTS, KindsEnum::TEXT_NOTE]);
} catch (\Exception $e) { $filter->setTag('#a', [$coordinate]);
$this->logger->error($e->getMessage()); $requestMessage = new RequestMessage($subscriptionId, [$filter]);
$this->managerRegistry->resetManager();
} $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) { $articles = [];
$article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event); // get npub relays, then look for articles
// check if event with same eventId already in DB $relayList = $this->getNpubRelays($pubkey);
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]); $relaySet = $this->defaultRelaySet;
if (!$saved) { foreach ($relayList as $r) {
try { // if (str_starts_with($r, 'wss:')) $relaySet->addRelay(new Relay($r));
$this->logger->info('Saving article', ['article' => $article]); }
$this->entityManager->persist($article); // look for last months long-form notes
$this->entityManager->flush(); $subscription = new Subscription();
} catch (\Exception $e) { $subscriptionId = $subscription->setId();
$this->logger->error($e->getMessage()); $filter = new Filter();
$this->managerRegistry->resetManager(); $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 @@
<?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);
namespace App\Twig\Components; namespace App\Twig\Components;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
class Header 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;
#[AsTwigComponent] #[AsTwigComponent]
final class Card final class Card
{ {
public string $tag = 'div';
public string $category = ''; public string $category = '';
public object $article; 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 @@
<?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;
use App\Service\NostrClient; use App\Service\NostrClient;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent] #[AsTwigComponent]
@ -13,9 +12,9 @@ final class UserFromNpub
{ {
public string $pubkey; public string $pubkey;
public string $npub; public string $npub;
public ?array $user = null; public $user = null;
public function __construct(private readonly NostrClient $nostrClient, private readonly CacheInterface $redisCache) public function __construct(private readonly NostrClient $nostrClient)
{ {
} }
@ -26,16 +25,10 @@ final class UserFromNpub
$this->npub = $keys->convertPublicKeyToBech32($pubkey); $this->npub = $keys->convertPublicKeyToBech32($pubkey);
try { try {
$this->user = $this->redisCache->get('user_' . $this->npub, function () { $meta = $this->nostrClient->getNpubMetadata($this->pubkey);
try { $this->user = (array) json_decode($meta->content);
$meta = $this->nostrClient->getNpubMetadata($this->npub);
return (array) json_decode($meta->content);
} catch (InvalidArgumentException|\Exception) { } catch (InvalidArgumentException|\Exception) {
return null; // nothing to do
}
});
} catch (InvalidArgumentException $e) {
$this->user = null;
} }
} }
} }

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

@ -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 @@
<?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 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Credits\Service\CreditsManager;
use FOS\ElasticaBundle\Finder\FinderInterface; 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\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\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
@ -14,30 +21,77 @@ final class SearchComponent
#[LiveProp(writable: true)] #[LiveProp(writable: true)]
public string $query = ''; public string $query = '';
public array $results = [];
public bool $interactive = true; 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)) { 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 try {
$filtered = array_filter($res, function($r) { $this->credits = $this->creditsManager->getBalance($this->npub);
return !str_contains($r->getSlug(), '/'); } 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 @@
<?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 @@
namespace App\Util\CommonMark; namespace App\Util\CommonMark;
use App\Util\Bech32\Bech32Decoder; use App\Util\Bech32\Bech32Decoder;
use App\Util\CommonMark\ImagesExtension\RawImageLinkExtension;
use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension; use App\Util\CommonMark\NostrSchemeExtension\NostrSchemeExtension;
use League\CommonMark\Environment\Environment; use League\CommonMark\Environment\Environment;
use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Exception\CommonMarkException;
@ -56,6 +57,7 @@ class Converter
// create a custom extension, that handles nostr mentions // create a custom extension, that handles nostr mentions
$environment->addExtension(new NostrSchemeExtension($this->bech32Decoder)); $environment->addExtension(new NostrSchemeExtension($this->bech32Decoder));
$environment->addExtension(new SmartPunctExtension()); $environment->addExtension(new SmartPunctExtension());
$environment->addExtension(new RawImageLinkExtension());
$environment->addExtension(new AutolinkExtension()); $environment->addExtension(new AutolinkExtension());
if ($headingsCount > 3) { if ($headingsCount > 3) {
$environment->addExtension(new HeadingPermalinkExtension()); $environment->addExtension(new HeadingPermalinkExtension());

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

@ -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 @@
<?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 @@
{% 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 @@
<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 @@
<div class="header__logo"><h1 class="brand"><a href="/">newsroom</a></h1></div> <div class="header__logo"><h1 class="brand"><a href="/">newsroom</a></h1></div>
<div class="header__categories"> <div class="header__categories">
<ul> <ul>
<li {% if app.current_route is same as("world") %}class="active"{% endif %}> {% for category in cats %}
<a href="/world">World</a> <li><twig:Molecules:CategoryLink coordinate="{{ category }}" /></li>
</li> {% endfor %}
<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>
</ul> </ul>
</div> </div>
</header> </header>

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

@ -1,28 +1,27 @@
{% if article is defined %} {% 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"> <div class="card-header">
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} {% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %}
{% if article.image %} {% if article.image %}
<img src="{{ article.image }}" alt=""> <img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" >
{% endif %} {% endif %}
</div> </div>
<div class="card-body"> <div class="card-body">
{# <small>{{ article.createdAt|date('F j') }}</small>#}
<h2 class="card-title">{{ article.title }}</h2> <h2 class="card-title">{{ article.title }}</h2>
<p class="lede"> <p class="lede">
{{ article.summary }} {{ article.summary }}
</p> </p>
</div> </div>
</{{ tag }}> </a>
{#<div class="card card-footer">#} <div class="card-footer"></div>
{# <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>
</div> </div>
</{{ tag }}>
{% endif %} {% endif %}

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

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

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

@ -1,7 +1,7 @@
<div {{ attributes }}> <div {{ attributes }}>
{% for item in list %} {% for item in list %}
{% if item.slug is not empty %} {% 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 %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>

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

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

6
templates/home.html.twig

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

8
templates/pages/article.html.twig

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

62
templates/pages/author.html.twig

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

5
templates/pages/search.html.twig

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

3
templates/static/about.html.twig

@ -7,7 +7,8 @@
<h1>About Decent Newsroom</h1> <h1>About Decent Newsroom</h1>
<h2>Rebuilding Trust in Journalism</h2> <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> <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 @@
{% block body %} {% block body %}
<h1>Roadmap: The Future of Decent Newsroom</h1> <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"> <div class="roadmap-section">
<h2>v0.1.0 – <strong>Preprint</strong> <span class="badge badge__secondary"><small>Current</small></span></h2> <h2>v0.1.0 – <strong>Preprint</strong> <span class="badge badge__secondary"><small>Current</small></span></h2>
<div class="milestone"> <div class="milestone">
<h3>A first glimpse into the future of journalism</h3> <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> <ul>
<li>Hand-curated, high-quality articles in magazine format</li> <li>Hand-curated, high-quality articles in magazine format</li>
<li>Full-text search to find the stories that matter to you</li> <li>Full-text search to find the stories that matter to you</li>
@ -28,11 +30,11 @@
<p>This milestone version introduces key features that bring Decent Newsroom to life:</p> <p>This milestone version introduces key features that bring Decent Newsroom to life:</p>
<ul> <ul>
<li>Zaps</li> <li>Zaps</li>
<li>Search and indexing requests enabled – writers and publishers can request inclusion</li> <li>Content submissions</li>
<li>Payable subscriptions and indexing via Lightning invoices</li> <li>Subscriptions for premium access</li>
<li>Built-in content editor publishers can create directly on the platform</li> <li>Built-in content editor so publishers can create directly on the platform</li>
</ul> </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> <p></p>
</div> </div>
</div> </div>

3
translations/messages.en.yaml

@ -11,3 +11,6 @@ heading:
editNzine: 'Edit your N-Zine' editNzine: 'Edit your N-Zine'
search: 'Search' search: 'Search'
index: 'Index' index: 'Index'
credit:
balance: '{0} No credits|{1} 1 credit|]1,Inf] %count% credits'

Loading…
Cancel
Save