Browse Source

Downsizing: remove elastic search

imwald
Nuša Pukšič 9 months ago
parent
commit
78f07758fa
  1. 6
      .env.dist
  2. 63
      README.md
  3. 1
      asset_map_compile.sh
  4. 2
      compose.override.yaml
  5. 1
      composer.json
  6. 49
      config/packages/fos_elastica.yaml
  7. 1
      config/packages/framework.yaml
  8. 20
      config/services.yaml
  9. 3
      docker/cron/index_articles.sh
  10. 29
      docs/alpine.md
  11. BIN
      docs/digitalocean-dns.png
  12. BIN
      docs/digitalocean-droplet.png
  13. 44
      docs/existing-project.md
  14. 18
      docs/extra-services.md
  15. 17
      docs/features.md
  16. 97
      docs/makefile.md
  17. 83
      docs/mysql.md
  18. 72
      docs/options.md
  19. 110
      docs/production.md
  20. 51
      docs/tls.md
  21. 9
      docs/troubleshooting.md
  22. 15
      docs/updating.md
  23. 58
      docs/xdebug.md
  24. 64
      src/Command/IndexArticlesCommand.php
  25. 17
      src/Command/NostrEventFromYamlDefinitionCommand.php
  26. 12
      src/Controller/AuthorController.php
  27. 48
      src/Controller/DefaultController.php
  28. 18
      src/Entity/Article.php
  29. 30
      src/EventListener/PopulateListener.php
  30. 27
      src/Provider/ArticleProvider.php
  31. 67
      src/Repository/ArticleRepository.php
  32. 52
      src/Twig/Components/Organisms/FeaturedList.php
  33. 65
      src/Twig/Components/SearchComponent.php
  34. 14
      src/Util/IndexableArticleChecker.php

6
.env.dist

@ -42,12 +42,6 @@ MERCURE_PUBLIC_URL=https://${SERVER_NAME}/.well-known/mercure
# The secret used to sign the JWTs # The secret used to sign the JWTs
MERCURE_JWT_SECRET="!NotSoSecretMercureHubJWTSecretKey!" MERCURE_JWT_SECRET="!NotSoSecretMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ### ###< symfony/mercure-bundle ###
###> elastic ###
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=elastic
ELASTICSEARCH_PASSWORD=your_password
###< elastic ###
###> redis ### ###> redis ###
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PASSWORD=r_password REDIS_PASSWORD=r_password

63
README.md

@ -1,71 +1,16 @@
# Decent Newsroom # Unfold
## Intro Unfold is a customizable framework for your Nostr-based magazine.
Decentralised Newsroom is a platform for the creation, publishing, and discovery of mixed-media collaborative journals.
Newsrooms used to be at the heart of journals and media houses, but they deteriorated when their business models started to fail.
This project is a decentralised digital alternative.
A lot of talented creators have found their opportunity in the handful of platforms available,
but there is synergy in collaboration that has been lost in the transition.
Let's bring back high-value professional journalism and collaborative publishing.
## Constituent parts
This project has multiple facets that build on each other, making the whole more than the sum of its parts.
### Reader
A traditional newspaper lookalike made up of multiple individual journals.
Logged-in users can pick and choose which journals they read and subscribe to,
while passers-by can browse the default public ones.
### Article Editor
A content editor interface for writing essays, articles, and more. Featuring preview mode, saved drafts and personal notes.
### Media Manager
In the current digital landscape, media content and written word have been driven apart, and it's time to bring them closer together again.
The media manager is a place to create and share your own media library.
### Marketplace
A marketplace for requesting custom-made media (photographs, graphics, data visualizations, animations, audio, video...), science review, contacts, etc. or
for publishing art and stock images to make them available and discoverable to be included in the journals.
### Newsroom
A content management system for creating and updating journals and managing subscriptions.
### Silk Search and Index
An integrated service that provides on-demand indexing and search.
## Setup ## Setup
### Clone the repository ### Clone the repository
```bash ```bash
git clone https://github.com/decent-newsroom/newsroom.git git clone https://github.com/decent-newsroom/unfold.git
cd newsroom cd unfold
``` ```
### Create the .env file ### Create the .env file
Copy the example file `.env.dist` and replace placeholders with your actual configuration. Copy the example file `.env.dist` and replace placeholders with your actual configuration.
### Add a project encryption key and nsec
Symfony uses a vault mechanism for managing secrets securely.
To save the nsec, run this command inside your Docker container:
```bash
docker-compose exec php bin/console secrets:set APP_NSEC
```
To save the encryption key:
```bash
docker-compose exec php bin/console secrets:set APP_ENCRYPTION_KEY
```

1
asset_map_compile.sh

@ -1 +0,0 @@
docker exec newsroom-php-1 php bin/console asset-map:compile

2
compose.override.yaml

@ -1,7 +1,7 @@
# Development environment override # Development environment override
services: services:
php: php:
image: newsroom-php image: unfold-php
build: build:
context: . context: .
target: frankenphp_dev target: frankenphp_dev

1
composer.json

@ -18,7 +18,6 @@
"embed/embed": "^4.4", "embed/embed": "^4.4",
"endroid/qr-code": "^6.0", "endroid/qr-code": "^6.0",
"endroid/qr-code-bundle": "^6.0", "endroid/qr-code-bundle": "^6.0",
"friendsofsymfony/elastica-bundle": "^6.5",
"laminas/laminas-diactoros": "^3.6", "laminas/laminas-diactoros": "^3.6",
"league/commonmark": "^2.7", "league/commonmark": "^2.7",
"league/html-to-markdown": "*", "league/html-to-markdown": "*",

49
config/packages/fos_elastica.yaml

@ -1,49 +0,0 @@
fos_elastica:
clients:
default:
host: '%env(ELASTICSEARCH_HOST)%'
port: '%env(int:ELASTICSEARCH_PORT)%'
username: '%env(ELASTICSEARCH_USERNAME)%'
password: '%env(ELASTICSEARCH_PASSWORD)%'
indexes:
# create the index by running php bin/console fos:elastica:populate
articles:
settings:
index:
# Increase refresh interval for better write performance
refresh_interval: "5s"
# Optimize indexing
number_of_shards: 1
number_of_replicas: 0
analysis:
analyzer:
custom_analyzer:
type: custom
tokenizer: standard
filter: [ lowercase, snowball, asciifolding ]
indexable_callback: [ 'App\Util\IndexableArticleChecker', 'isIndexable' ]
properties:
createdAt:
type: keyword
title:
type: text
analyzer: custom_analyzer
content:
type: text
analyzer: custom_analyzer
summary:
type: text
analyzer: custom_analyzer
tags:
type: keyword
slug:
type: keyword
pubkey:
type: keyword
topics: ~
persistence:
driver: orm
model: App\Entity\Article
provider: ~
listener: ~
finder: ~

1
config/packages/framework.yaml

@ -9,7 +9,6 @@ framework:
cookie_secure: auto cookie_secure: auto
cookie_samesite: lax cookie_samesite: lax
cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session' cookie_lifetime: 0 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session'
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto'] trusted_headers: ['forwarded', 'x-forwarded-for', 'x-forwarded-proto']
# trusted_proxies: '%env(TRUSTED_PROXIES)%' # trusted_proxies: '%env(TRUSTED_PROXIES)%'
#trusted_proxies: 'symfony,REMOTE_ADDR' #trusted_proxies: 'symfony,REMOTE_ADDR'

20
config/services.yaml

@ -28,10 +28,6 @@ services:
arguments: arguments:
- '%env(DATABASE_URL)%' - '%env(DATABASE_URL)%'
#
FOS\ElasticaBundle\Finder\FinderInterface:
alias: fos_elastica.finder.articles
# Redis # Redis
Symfony\Component\Cache\Adapter\RedisAdapter: Symfony\Component\Cache\Adapter\RedisAdapter:
arguments: arguments:
@ -54,19 +50,3 @@ services:
- '%env(REDIS_HOST)%' - '%env(REDIS_HOST)%'
- auth: - auth:
- '%env(REDIS_PASSWORD)%' - '%env(REDIS_PASSWORD)%'
App\Provider\ArticleProvider:
tags:
- { name: fos_elastica.pager_provider, index: articles, type: article }
App\EventListener\PopulateListener:
tags:
- { name: kernel.event_listener, event: 'FOS\ElasticaBundle\Event\PostIndexPopulateEvent', method: 'postIndexPopulate' }
App\Command\IndexArticlesCommand:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'
App\Command\NostrEventFromYamlDefinitionCommand:
arguments:
$itemPersister: '@fos_elastica.object_persister.articles'

3
docker/cron/index_articles.sh

@ -2,8 +2,5 @@
set -e set -e
export PATH="/usr/local/bin:/usr/bin:/bin" export PATH="/usr/local/bin:/usr/bin:/bin"
# Run Symfony commands sequentially
php /var/www/html/bin/console articles:get -- '-1 week' 'now' php /var/www/html/bin/console articles:get -- '-1 week' 'now'
php /var/www/html/bin/console articles:qa php /var/www/html/bin/console articles:qa
php /var/www/html/bin/console articles:index
php /var/www/html/bin/console articles:indexed

29
docs/alpine.md

@ -1,29 +0,0 @@
# Using Alpine Linux Instead of Debian
By default, Symfony Docker uses Debian-based FrankenPHP Docker images.
This is the recommended solution.
Alternatively, it's possible to use Alpine-based images, which are smaller but
are known to be slower, and have several known issues.
To switch to Alpine-based images, apply the following changes to the `Dockerfile`:
```patch
-FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream
+FROM dunglas/frankenphp:1-php8.3-alpine AS frankenphp_upstream
-# hadolint ignore=DL3008
-RUN apt-get update && apt-get install -y --no-install-recommends \
- acl \
- file \
- gettext \
- git \
- && rm -rf /var/lib/apt/lists/*
+# hadolint ignore=DL3018
+RUN apk add --no-cache \
+ acl \
+ file \
+ gettext \
+ git \
+ ;
```

BIN
docs/digitalocean-dns.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

BIN
docs/digitalocean-droplet.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

44
docs/existing-project.md

@ -1,44 +0,0 @@
# Installing on an Existing Project
It's also possible to use Symfony Docker with existing projects!
First, [download this skeleton](https://github.com/dunglas/symfony-docker).
If you cloned the Git repository, be sure to not copy the `.git` directory to prevent conflicts with the `.git` directory already in your existing project.
You can copy the contents of the repository using git and tar. This will not contain `.git` or any uncommited changes.
git archive --format=tar HEAD | tar -xC my-existing-project/
If you downloaded the skeleton as a zip you can just copy the extracted files:
cp -Rp symfony-docker/. my-existing-project/
Enable the Docker support of Symfony Flex:
composer config --json extra.symfony.docker 'true'
Re-execute the recipes to update the Docker-related files according to the packages you use
rm symfony.lock
composer recipes:install --force --verbose
Double-check the changes, revert the changes that you don't want to keep:
git diff
...
Build the Docker images:
docker compose build --no-cache --pull
Start the project!
docker compose up -d
Browse `https://localhost`, your Docker configuration is ready!
> [!NOTE]
> If you want to use the worker mode of FrankenPHP, make sure you required the `runtime/frankenphp-symfony` package.
> [!NOTE]
> The worker mode of FrankenPHP is enabled by default in prod. To disabled it, add the env var FRANKENPHP_CONFIG as empty to the compose.prod.yaml file.

18
docs/extra-services.md

@ -1,18 +0,0 @@
# Support for Extra Services
Symfony Docker is extensible. When you install a compatible Composer package using Symfony Flex,
the recipe will automatically modify the `Dockerfile` and `compose.yaml` to fulfill the requirements of this package.
The currently supported packages are:
* `symfony/orm-pack`: install a PostgreSQL service
* `symfony/mercure-bundle`: use the Mercure.rocks module shipped with Caddy
* `symfony/panther`: install chromium and these drivers
* `symfony/mailer`: install a Mailpit service
* `blackfireio/blackfire-symfony-meta`: install a Blackfire service
> [!NOTE]
> If a recipe modifies the Dockerfile, the container needs to be rebuilt.
> [!WARNING]
> We recommend that you use the `composer require` command inside the container in development mode so that recipes can be applied correctly

17
docs/features.md

@ -0,0 +1,17 @@
# Features
## Search Functionality (REMOVED)
- **Status**: Being removed as part of scaling down
- **Previous implementation**: Used Elasticsearch via FOSElasticaBundle
- **Replacement**: Will need basic database-based search for articles by title/content if search is still needed
- **Components affected**:
- SearchComponent (Twig component)
- FeaturedList component
- Article indexing commands
- Controllers using Elasticsearch queries
## Core Features to Preserve
- Article management (CRUD operations)
- Article display and listing
- Author pages
- Basic article filtering (should use database queries instead of Elasticsearch)

97
docs/makefile.md

@ -1,97 +0,0 @@
# Makefile
Here is a Makefile template. It provides some shortcuts for the most common tasks.
To use it, create a new `Makefile` file at the root of your project. Copy/paste
the content in the template section. To view all the available commands, run `make`.
For example, in the [getting started section](/README.md#getting-started), the
`docker compose` commands could be replaced by:
1. Run `make build` to build fresh images
2. Run `make up` (detached mode without logs)
3. Run `make down` to stop the Docker containers
Of course, this template is basic for now. But, as your application is growing,
you will probably want to add some targets like running your tests as described
in [the Symfony book](https://symfony.com/doc/current/the-fast-track/en/17-tests.html#automating-your-workflow-with-a-makefile).
You can also find a more complete example in this [snippet](https://www.strangebuzz.com/en/snippets/the-perfect-makefile-for-symfony).
If you want to run make from within the `php` container, in the [Dockerfile](/Dockerfile),
add:
```diff
gettext \
git \
+make \
```
And rebuild the PHP image.
> [!NOTE]
> If you are using Windows, you have to install [chocolatey.org](https://chocolatey.org/) or [Cygwin](http://cygwin.com) to use the `make` command. Check out this [StackOverflow question](https://stackoverflow.com/q/2532234/633864) for more explanations.
## The template
```Makefile
# Executables (local)
DOCKER_COMP = docker compose
# Docker containers
PHP_CONT = $(DOCKER_COMP) exec php
# Executables
PHP = $(PHP_CONT) php
COMPOSER = $(PHP_CONT) composer
SYMFONY = $(PHP) bin/console
# Misc
.DEFAULT_GOAL = help
.PHONY : help build up start down logs sh composer vendor sf cc test
## —— 🎵 🐳 The Symfony Docker Makefile 🐳 🎵 ——————————————————————————————————
help: ## Outputs this help screen
@grep -E '(^[a-zA-Z0-9\./_-]+:.*?##.*$$)|(^##)' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}{printf "\033[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m##/[33m/'
## —— Docker 🐳 ————————————————————————————————————————————————————————————————
build: ## Builds the Docker images
@$(DOCKER_COMP) build --pull --no-cache
up: ## Start the docker hub in detached mode (no logs)
@$(DOCKER_COMP) up --detach
start: build up ## Build and start the containers
down: ## Stop the docker hub
@$(DOCKER_COMP) down --remove-orphans
logs: ## Show live logs
@$(DOCKER_COMP) logs --tail=0 --follow
sh: ## Connect to the FrankenPHP container
@$(PHP_CONT) sh
bash: ## Connect to the FrankenPHP container via bash so up and down arrows go to previous commands
@$(PHP_CONT) bash
test: ## Start tests with phpunit, pass the parameter "c=" to add options to phpunit, example: make test c="--group e2e --stop-on-failure"
@$(eval c ?=)
@$(DOCKER_COMP) exec -e APP_ENV=test php bin/phpunit $(c)
## —— Composer 🧙 ——————————————————————————————————————————————————————————————
composer: ## Run composer, pass the parameter "c=" to run a given command, example: make composer c='req symfony/orm-pack'
@$(eval c ?=)
@$(COMPOSER) $(c)
vendor: ## Install vendors according to the current composer.lock file
vendor: c=install --prefer-dist --no-dev --no-progress --no-scripts --no-interaction
vendor: composer
## —— Symfony 🎵 ———————————————————————————————————————————————————————————————
sf: ## List all Symfony commands or pass the parameter "c=" to run a given command, example: make sf c=about
@$(eval c ?=)
@$(SYMFONY) $(c)
cc: c=c:c ## Clear the cache
cc: sf
```

83
docs/mysql.md

@ -1,83 +0,0 @@
# Using MySQL
The Docker configuration of this repository is extensible thanks to Flex recipes. By default, the recipe installs PostgreSQL.
If you prefer to work with MySQL, follow these steps:
First, install the `symfony/orm-pack` package as described: `docker compose exec php composer req symfony/orm-pack`
## Docker Configuration
Change the database image to use MySQL instead of PostgreSQL in `compose.yaml`:
```diff
###> doctrine/doctrine-bundle ###
- image: postgres:${POSTGRES_VERSION:-15}-alpine
+ image: mysql:${MYSQL_VERSION:-8}
environment:
- POSTGRES_DB: ${POSTGRES_DB:-app}
+ MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
# You should definitely change the password in production
+ MYSQL_RANDOM_ROOT_PASSWORD: "true"
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
+ MYSQL_PASSWORD: ${MYSQL_PASSWORD:-!ChangeMe!}
- POSTGRES_USER: ${POSTGRES_USER:-app}
+ MYSQL_USER: ${MYSQL_USER:-app}
healthcheck:
- test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
+ test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- - database_data:/var/lib/postgresql/data:rw
+ - database_data:/var/lib/mysql:rw
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
- # - ./docker/db/data:/var/lib/postgresql/data:rw
+ # - ./docker/db/data:/var/lib/mysql:rw
###< doctrine/doctrine-bundle ###
```
Depending on the database configuration, modify the environment in the same file at `services.php.environment.DATABASE_URL`
```
DATABASE_URL: mysql://${MYSQL_USER:-app}:${MYSQL_PASSWORD:-!ChangeMe!}@database:3306/${MYSQL_DATABASE:-app}?serverVersion=${MYSQL_VERSION:-8}&charset=${MYSQL_CHARSET:-utf8mb4}
```
Since we changed the port, we also have to define this in the `compose.override.yaml`:
```diff
###> doctrine/doctrine-bundle ###
database:
ports:
- - "5432"
+ - "3306"
###< doctrine/doctrine-bundle ###
```
Last but not least, we need to install the MySQL driver in `Dockerfile`:
```diff
###> doctrine/doctrine-bundle ###
-RUN install-php-extensions pdo_pgsql
+RUN install-php-extensions pdo_mysql
###< doctrine/doctrine-bundle ###
```
## Change Environment
Change the database configuration in `.env`:
```dotenv
DATABASE_URL=mysql://${MYSQL_USER:-app}:${MYSQL_PASSWORD:-!ChangeMe!}@database:3306/${MYSQL_DATABASE:-app}?serverVersion=${MYSQL_VERSION:-8}&charset=${MYSQL_CHARSET:-utf8mb4}
```
## Final steps
Rebuild the docker environment:
```shell
docker compose down --remove-orphans && docker compose build --pull --no-cache
```
Start the services:
```shell
docker compose up -d
```
Test your setup:
```shell
docker compose exec php bin/console dbal:run-sql -q "SELECT 1" && echo "OK" || echo "Connection is not working"
```

72
docs/options.md

@ -1,72 +0,0 @@
# Docker Build Options
You can customize the docker build process using these environment variables.
> [!NOTE]
> All Symfony-specific environment variables are used only if no `composer.json` file is found in the project directory.
## Selecting a Specific Symfony Version
Use the `SYMFONY_VERSION` environment variable to select a specific Symfony version.
For instance, use the following command to install Symfony 6.4:
On Linux:
SYMFONY_VERSION=6.4.* docker compose up -d --wait
On Windows:
set SYMFONY_VERSION=6.4.* && docker compose up -d --wait&set SYMFONY_VERSION=
## Installing Development Versions of Symfony
To install a non-stable version of Symfony, use the `STABILITY` environment variable during the build.
The value must be [a valid Composer stability option](https://getcomposer.org/doc/04-schema.md#minimum-stability).
For instance, use the following command to use the development branch of Symfony:
On Linux:
STABILITY=dev docker compose up -d --wait
On Windows:
set STABILITY=dev && docker compose up -d --wait&set STABILITY=
## Using custom HTTP ports
Use the environment variables `HTTP_PORT`, `HTTPS_PORT` and/or `HTTP3_PORT` to adjust the ports to your needs, e.g.
HTTP_PORT=8000 HTTPS_PORT=4443 HTTP3_PORT=4443 docker compose up -d --wait
to access your application on [https://localhost:4443](https://localhost:4443).
> [!NOTE]
> Let's Encrypt only supports the standard HTTP and HTTPS ports. Creating a Let's Encrypt certificate for another port will not work, you have to use the standard ports or to configure Caddy to use another provider.
## Caddyfile Options
You can also customize the `Caddyfile` by using the following environment variables to inject options block, directive or configuration.
> [!TIP]
> All the following environment variables can be defined in your `.env` file at the root of the project to keep them persistent at each startup
| Environment variable | Description | Default value |
|---------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `CADDY_GLOBAL_OPTIONS` | the [global options block](https://caddyserver.com/docs/caddyfile/options#global-options), one per line | |
| `CADDY_EXTRA_CONFIG` | the [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets) or the [named-routes](https://caddyserver.com/docs/caddyfile/concepts#named-routes) options block, one per line | |
| `CADDY_SERVER_EXTRA_DIRECTIVES` | the [`Caddyfile` directives](https://caddyserver.com/docs/caddyfile/concepts#directives) | |
| `CADDY_SERVER_LOG_OPTIONS` | the [server log options block](https://caddyserver.com/docs/caddyfile/directives/log), one per line | |
| `SERVER_NAME` | the server name or address | `localhost` |
| `FRANKENPHP_CONFIG` | a list of extra [FrankenPHP directives](https://frankenphp.dev/docs/config/#caddyfile-config), one per line | `import worker.Caddyfile` |
| `MERCURE_TRANSPORT_URL` | the value passed to the `transport_url` directive | `bolt://mercure.db` |
| `MERCURE_PUBLISHER_JWT_KEY` | the JWT key to use for publishers | |
| `MERCURE_PUBLISHER_JWT_ALG` | the JWT algorithm to use for publishers | `HS256` |
| `MERCURE_SUBSCRIBER_JWT_KEY` | the JWT key to use for subscribers | |
| `MERCURE_SUBSCRIBER_JWT_ALG` | the JWT algorithm to use for subscribers | `HS256` |
| `MERCURE_EXTRA_DIRECTIVES` | a list of extra [Mercure directives](https://mercure.rocks/docs/hub/config), one per line | |
### Example of server name customize:
SERVER_NAME="app.localhost" docker compose up -d --wait

110
docs/production.md

@ -1,110 +0,0 @@
# Deploying in Production
Symfony Docker provides Docker images, and a Docker Compose definition optimized for production usage.
In this tutorial, we will learn how to deploy our Symfony application on a single server using Docker Compose.
## Preparing a Server
To deploy your application in production, you need a server.
In this tutorial, we will use a virtual machine provided by DigitalOcean, but any Linux server can work.
If you already have a Linux server with Docker Compose installed, you can skip straight to [the next section](#configuring-a-domain-name).
Otherwise, use [this affiliate link](https://m.do.co/c/5d8aabe3ab80) to get $100 of free credit, create an account, then click on "Create a Droplet".
Then, click on the "Marketplace" tab under the "Choose an image" section and search for the app named "Docker".
This will provision an Ubuntu server with the latest versions of Docker and Docker Compose already installed!
For test purposes, the cheapest plans will be enough, even though you might want at least 2GB of RAM to execute Docker Compose for the first time. For real production usage, you'll probably want to pick a plan in the "general purpose" section to fit your needs.
![Deploying a Symfony app on DigitalOcean with Docker Compose](digitalocean-droplet.png)
You can keep the defaults for other settings, or tweak them according to your needs.
Don't forget to add your SSH key or create a password then press the "Finalize and create" button.
Then, wait a few seconds while your Droplet is provisioning.
When your Droplet is ready, use SSH to connect:
```console
ssh root@<droplet-ip>
```
## Configuring a Domain Name
In most cases, you'll want to associate a domain name with your site.
If you don't own a domain name yet, you'll have to buy one through a registrar.
Then create a DNS record of type `A` for your domain name pointing to the IP address of your server:
```dns
your-domain-name.example.com. IN A 207.154.233.113
```
Example with the DigitalOcean Domains service ("Networking" > "Domains"):
![Configuring DNS on DigitalOcean](digitalocean-dns.png)
> [!NOTE]
> Let's Encrypt, the service used by default by Symfony Docker to automatically generate a TLS certificate doesn't support using bare IP addresses. Using a domain name is mandatory to use Let's Encrypt.
## Deploying
Copy your project on the server using `git clone`, `scp`, or any other tool that may fit your need.
If you use GitHub, you may want to use [a deploy key](https://docs.github.com/en/free-pro-team@latest/developers/overview/managing-deploy-keys#deploy-keys).
Deploy keys are also [supported by GitLab](https://docs.gitlab.com/ee/user/project/deploy_keys/).
Example with Git:
```console
git clone git@github.com:<username>/<project-name>.git
```
Go into the directory containing your project (`<project-name>`), and start the app in production mode:
```console
SERVER_NAME=your-domain-name.example.com \
APP_SECRET=ChangeMe \
CADDY_MERCURE_JWT_SECRET=ChangeThisMercureHubJWTSecretKey \
docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
```
Be sure to replace `your-domain-name.example.com` with your actual domain name and to set the values of `APP_SECRET`, `CADDY_MERCURE_JWT_SECRET` to cryptographically secure random values.
Your server is up and running, and a HTTPS certificate has been automatically generated for you.
Go to `https://your-domain-name.example.com` and enjoy!
> [!NOTE]
> The worker mode of FrankenPHP is enabled by default in prod. To disable it, add the env var FRANKENPHP_CONFIG as empty to the compose.prod.yaml file.
> [!CAUTION]
> Docker can have a cache layer, make sure you have the right build for each deployment or rebuild your project with --no-cache option to avoid cache issue.
## Disabling HTTPS
Alternatively, if you don't want to expose an HTTPS server but only an HTTP one, run the following command:
```console
SERVER_NAME=:80 \
APP_SECRET=ChangeMe \
CADDY_MERCURE_JWT_SECRET=ChangeThisMercureHubJWTSecretKey \
docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
```
## Deploying on Multiple Nodes
If you want to deploy your app on a cluster of machines, you can use [Docker Swarm](https://docs.docker.com/engine/swarm/stack-deploy/),
which is compatible with the provided Compose files.
To deploy on Kubernetes, take a look at [the Helm chart provided with API Platform](https://api-platform.com/docs/deployment/kubernetes/), which can be easily adapted for use with Symfony Docker.
## Passing local environment variables to containers
By default, `.env.local` and `.env.*.local` files are excluded from production images.
If you want to pass them to your containers, you can use the [`env_file` attribute](https://docs.docker.com/compose/environment-variables/set-environment-variables/#use-the-env_file-attribute):
```yaml
# compose.prod.yml
services:
php:
env_file:
- .env.prod.local
# ...
```

51
docs/tls.md

@ -1,51 +0,0 @@
# TLS Certificates
## Trusting the Authority
With a standard installation, the authority used to sign certificates generated in the Caddy container is not trusted by your local machine.
You must add the authority to the trust store of the host :
```
# Mac
$ docker cp $(docker compose ps -q php):/data/caddy/pki/authorities/local/root.crt /tmp/root.crt && sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /tmp/root.crt
# Linux
$ docker cp $(docker compose ps -q php):/data/caddy/pki/authorities/local/root.crt /usr/local/share/ca-certificates/root.crt && sudo update-ca-certificates
# Windows
$ docker compose cp php:/data/caddy/pki/authorities/local/root.crt %TEMP%/root.crt && certutil -addstore -f "ROOT" %TEMP%/root.crt
```
## Using Custom TLS Certificates
By default, Caddy will automatically generate TLS certificates using Let's Encrypt or ZeroSSL.
But sometimes you may prefer using custom certificates.
For instance, to use self-signed certificates created with [mkcert](https://github.com/FiloSottile/mkcert) do as follows:
1. Locally install `mkcert`
2. Create the folder storing the certs:
`mkdir frankenphp/certs -p`
3. Generate the certificates for your local host (example: "server-name.localhost"):
`mkcert -cert-file frankenphp/certs/tls.pem -key-file frankenphp/certs/tls.key "server-name.localhost"`
4. Add these lines to the `./compose.override.yaml` file about `CADDY_SERVER_EXTRA_DIRECTIVES` environment and volume for the `php` service :
```diff
php:
environment:
+ CADDY_SERVER_EXTRA_DIRECTIVES: "tls /etc/caddy/certs/tls.pem /etc/caddy/certs/tls.key"
# ...
volumes:
+ - ./frankenphp/certs:/etc/caddy/certs:ro
- ./public:/app/public:ro
```
5. Restart your `php` service
## Disabling HTTPS for Local Development
To disable HTTPS, configure your environment to use HTTP by setting the following variables and starting the project with this command:
```bash
SERVER_NAME=http://localhost \
MERCURE_PUBLIC_URL=http://localhost/.well-known/mercure \
docker compose up --pull always -d --wait
```
Ensure your application is accessible over HTTP by visiting `http://localhost` in your web browser.

9
docs/troubleshooting.md

@ -1,9 +0,0 @@
# Troubleshooting
## Editing Permissions on Linux
If you work on linux and cannot edit some of the project files right after the first installation, you can run `docker compose run --rm php chown -R $(id -u):$(id -g) .` to set yourself as owner of the project files that were created by the docker container.
## TLS/HTTPS Issues
See more in the [TLS section](tls.md)

15
docs/updating.md

@ -1,15 +0,0 @@
# Updating Your Project
To import the changes made to the *Symfony Docker* template into your project, we recommend using
[*template-sync*](https://github.com/coopTilleuls/template-sync):
1. Run the script to synchronize your project with the latest version of the skeleton:
```console
curl -sSL https://raw.githubusercontent.com/coopTilleuls/template-sync/main/template-sync.sh | sh -s -- https://github.com/dunglas/symfony-docker
```
2. Resolve conflicts, if any
3. Run `git cherry-pick --continue`
For more advanced options, refer to [the documentation of *template sync*](https://github.com/coopTilleuls/template-sync#template-sync).

58
docs/xdebug.md

@ -1,58 +0,0 @@
# Using Xdebug
The default development image is shipped with [Xdebug](https://xdebug.org/),
a popular debugger and profiler for PHP.
Because it has a significant performance overhead, the step-by-step debugger is disabled by default.
It can be enabled by setting the `XDEBUG_MODE` environment variable to `debug`.
On Linux and Mac:
```
XDEBUG_MODE=debug docker compose up -d
```
On Windows:
```
set XDEBUG_MODE=debug&& docker compose up -d&set XDEBUG_MODE=
```
## Debugging with Xdebug and PHPStorm
First, [create a PHP debug remote server configuration](https://www.jetbrains.com/help/phpstorm/creating-a-php-debug-server-configuration.html):
1. In the `Settings/Preferences` dialog, go to `PHP | Servers`
2. Create a new server:
* Name: `symfony` (or whatever you want to use for the variable `PHP_IDE_CONFIG`)
* Host: `localhost` (or the one defined using the `SERVER_NAME` environment variable)
* Port: `443`
* Debugger: `Xdebug`
* Check `Use path mappings`
* Absolute path on the server: `/app`
You can now use the debugger!
1. In PHPStorm, open the `Run` menu and click on `Start Listening for PHP Debug Connections`
2. Add the `XDEBUG_SESSION=PHPSTORM` query parameter to the URL of the page you want to debug, or use [other available triggers](https://xdebug.org/docs/step_debug#activate_debugger)
Alternatively, you can use [the **Xdebug extension**](https://xdebug.org/docs/step_debug#browser-extensions) for your preferred web browser.
3. On command line, we might need to tell PHPStorm which [path mapping configuration](https://www.jetbrains.com/help/phpstorm/zero-configuration-debugging-cli.html#configure-path-mappings) should be used, set the value of the PHP_IDE_CONFIG environment variable to `serverName=symfony`, where `symfony` is the name of the debug server configured higher.
Example:
```console
XDEBUG_SESSION=1 PHP_IDE_CONFIG="serverName=symfony" php bin/console ...
```
## Troubleshooting
Inspect the installation with the following command. The Xdebug version should be displayed.
```console
$ docker compose exec php php --version
PHP ...
with Xdebug v3.x.x ...
```

64
src/Command/IndexArticlesCommand.php

@ -1,64 +0,0 @@
<?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);
}
}

17
src/Command/NostrEventFromYamlDefinitionCommand.php

@ -4,11 +4,9 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Enum\IndexStatusEnum;
use App\Factory\ArticleFactory; use App\Factory\ArticleFactory;
use App\Service\NostrClient; use App\Service\NostrClient;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use swentel\nostr\Sign\Sign; use swentel\nostr\Sign\Sign;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
@ -30,7 +28,6 @@ class NostrEventFromYamlDefinitionCommand extends Command
private readonly NostrClient $client, private readonly NostrClient $client,
private readonly ArticleFactory $factory, private readonly ArticleFactory $factory,
ParameterBagInterface $bag, ParameterBagInterface $bag,
private readonly ObjectPersisterInterface $itemPersister,
private readonly EntityManagerInterface $entityManager) private readonly EntityManagerInterface $entityManager)
{ {
$this->nsec = $bag->get('nsec'); $this->nsec = $bag->get('nsec');
@ -105,24 +102,12 @@ class NostrEventFromYamlDefinitionCommand extends Command
$articles = []; $articles = [];
foreach ($fresh as $item) { foreach ($fresh as $item) {
$article = $this->factory->createFromLongFormContentEvent($item); $article = $this->factory->createFromLongFormContentEvent($item);
$article->setIndexStatus(IndexStatusEnum::TO_BE_INDEXED);
$this->entityManager->persist($article); $this->entityManager->persist($article);
$articles[] = $article; $articles[] = $article;
} }
$this->entityManager->flush(); $this->entityManager->flush();
// to elastic $output->writeln('<info>Articles saved to database.</info>');
if (count($articles) > 0 ) {
$this->itemPersister->insertMany($articles); // Insert or skip existing
// Set all articles as indexed
foreach ($articles as $article) {
$article->setIndexStatus(IndexStatusEnum::INDEXED);
$this->entityManager->persist($article);
}
$this->entityManager->flush();
$output->writeln('<info>Added to index.</info>');
}
$output->writeln('<info>Conversion complete.</info>'); $output->writeln('<info>Conversion complete.</info>');
return Command::SUCCESS; return Command::SUCCESS;
} }

12
src/Controller/AuthorController.php

@ -4,11 +4,10 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Repository\ArticleRepository;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use Elastica\Query\Terms;
use Exception; use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -20,7 +19,7 @@ class AuthorController extends AbstractController
* @throws Exception * @throws Exception
*/ */
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
public function index($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, FinderInterface $finder): Response public function index($npub, NostrClient $nostrClient, RedisCacheService $redisCacheService, ArticleRepository $articleRepository): Response
{ {
$keys = new Key(); $keys = new Key();
$pubkey = $keys->convertToHex($npub); $pubkey = $keys->convertToHex($npub);
@ -32,9 +31,10 @@ class AuthorController extends AbstractController
} catch (Exception $e) { } catch (Exception $e) {
$list = []; $list = [];
} }
// Also look for articles in the Elastica index
$query = new Terms('pubkey', [$pubkey]); // Also look for articles in the database by pubkey
$list = array_merge($list, $finder->find($query, 25)); $dbArticles = $articleRepository->findByPubkey($pubkey, 25);
$list = array_merge($list, $dbArticles);
$articles = []; $articles = [];
// Deduplicate by slugs // Deduplicate by slugs

48
src/Controller/DefaultController.php

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use Elastica\Query; use App\Repository\ArticleRepository;
use Elastica\Query\Terms;
use Exception; use Exception;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -50,7 +48,7 @@ class DefaultController extends AbstractController
*/ */
#[Route('/cat/{slug}', name: 'magazine-category')] #[Route('/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, CacheInterface $redisCache, public function magCategory($slug, CacheInterface $redisCache,
FinderInterface $finder, ArticleRepository $articleRepository,
LoggerInterface $logger): Response LoggerInterface $logger): Response
{ {
$catIndex = $redisCache->get('magazine-' . $slug, function (){ $catIndex = $redisCache->get('magazine-' . $slug, function (){
@ -75,21 +73,15 @@ class DefaultController extends AbstractController
} }
if (!empty($coordinates)) { if (!empty($coordinates)) {
// Extract slugs for elasticsearch query // Extract slugs for database query
$slugs = array_map(function($coordinate) { $slugs = array_map(function($coordinate) {
$parts = explode(':', $coordinate, 3); $parts = explode(':', $coordinate, 3);
return end($parts); return end($parts);
}, $coordinates); }, $coordinates);
$slugs = array_filter($slugs); // Remove empty values $slugs = array_filter($slugs); // Remove empty values
// First filter to only include articles with the slugs we want // Use database query instead of Elasticsearch
$termsQuery = new Terms('slug', array_values($slugs)); $articles = $articleRepository->findBySlugsCriteria($slugs);
// Create a Query object to set the size parameter
$query = new Query($termsQuery);
$query->setSize(200); // Set size to exceed the number of articles we expect
$articles = $finder->find($query);
// Create a map of slug => item to remove duplicates // Create a map of slug => item to remove duplicates
$slugMap = []; $slugMap = [];
@ -118,46 +110,26 @@ class DefaultController extends AbstractController
} }
} }
// If we have missing articles, fetch them directly using NostrClient's getArticlesByCoordinates // If we have missing articles, log them for now
if (!empty($missingCoordinates)) { if (!empty($missingCoordinates)) {
$logger->info('There were missing articles', [ $logger->info('There were missing articles', [
'missing' => $missingCoordinates 'missing' => $missingCoordinates
]); ]);
// Note: Removed NostrClient fetching logic for simplification
// try {
// $nostrArticles = $nostrClient->getArticlesByCoordinates($missingCoordinates);
//
// foreach ($nostrArticles as $coordinate => $event) {
// $parts = explode(':', $coordinate);
// if (count($parts) === 3) {
// $article = $articleFactory->createFromLongFormContentEvent($event);
// // Save article to database for future queries
// $nostrClient->saveEachArticleToTheDatabase($article);
// // Add to the slugMap
// $slugMap[$article->getSlug()] = $article;
// }
// }
// } catch (\Exception $e) {
// $logger->error('Error fetching missing articles', [
// 'error' => $e->getMessage()
// ]);
// }
} }
// Build ordered list based on original coordinates order // Build ordered list based on original coordinates order
foreach ($coordinates as $coordinate) { foreach ($coordinates as $coordinate) {
$parts = explode(':', $coordinate,3); $parts = explode(':', $coordinate, 3);
if (isset($slugMap[end($parts)])) { if (isset($slugMap[end($parts)])) {
$list[] = $slugMap[end($parts)]; $list[] = $slugMap[end($parts)];
} }
} }
} }
return $this->render('pages/category.html.twig', [ return $this->render('magazine-category.html.twig', [
'list' => $list, 'list' => $list,
'category' => $category, 'category' => $category
'index' => $catIndex
]); ]);
} }
} }

18
src/Entity/Article.php

@ -3,18 +3,15 @@
namespace App\Entity; namespace App\Entity;
use App\Enum\EventStatusEnum; use App\Enum\EventStatusEnum;
use App\Enum\IndexStatusEnum;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use FOS\ElasticaBundle\Provider\IndexableInterface;
/** /**
* Entity storing long-form articles * Entity storing long-form articles
* Needed beyond the Event entity, because of the local functionalities built on top of the original events * Needed beyond the Event entity, because of the local functionalities built on top of the original events
* - editor * - editor
* - indexing and search
* NIP-23, kinds 30023, 30024 * NIP-23, kinds 30023, 30024
*/ */
#[ORM\Entity(repositoryClass: ArticleRepository::class)] #[ORM\Entity(repositoryClass: ArticleRepository::class)]
@ -67,9 +64,6 @@ class Article
#[ORM\Column(nullable: true, enumType: EventStatusEnum::class)] #[ORM\Column(nullable: true, enumType: EventStatusEnum::class)]
private ?EventStatusEnum $eventStatus = EventStatusEnum::PREVIEW; private ?EventStatusEnum $eventStatus = EventStatusEnum::PREVIEW;
#[ORM\Column(nullable: true, enumType: IndexStatusEnum::class)]
private ?IndexStatusEnum $indexStatus = IndexStatusEnum::NOT_INDEXED;
// Local properties // Local properties
#[ORM\Column(type: Types::JSON, nullable: true)] #[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $currentPlaces; private ?array $currentPlaces;
@ -270,18 +264,6 @@ class Article
return $this; return $this;
} }
public function getIndexStatus(): ?IndexStatusEnum
{
return $this->indexStatus;
}
public function setIndexStatus(?IndexStatusEnum $indexStatus): static
{
$this->indexStatus = $indexStatus;
return $this;
}
public function getCurrentPlaces(): ?array public function getCurrentPlaces(): ?array
{ {
return $this->currentPlaces; return $this->currentPlaces;

30
src/EventListener/PopulateListener.php

@ -1,30 +0,0 @@
<?php
namespace App\EventListener;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Event\PostIndexPopulateEvent;
class PopulateListener
{
public function __construct( private readonly EntityManagerInterface $entityManager)
{
}
public function postIndexPopulate(PostIndexPopulateEvent $event): void
{
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]);
foreach ($articles as $article) {
if ($article instanceof Article) {
$article->setIndexStatus(IndexStatusEnum::INDEXED);
$this->entityManager->persist($article);
}
}
$this->entityManager->flush();
}
}

27
src/Provider/ArticleProvider.php

@ -1,27 +0,0 @@
<?php
namespace App\Provider;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Provider\PagerfantaPager;
use FOS\ElasticaBundle\Provider\PagerInterface;
use FOS\ElasticaBundle\Provider\PagerProviderInterface;
use Pagerfanta\Adapter\ArrayAdapter;
use Pagerfanta\Pagerfanta;
class ArticleProvider implements PagerProviderInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function provide(array $options = []): PagerInterface
{
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED],['createdAt' => 'ASC'],200);
return new PagerfantaPager(new Pagerfanta(new ArrayAdapter($articles)));
}
}

67
src/Repository/ArticleRepository.php

@ -15,4 +15,71 @@ class ArticleRepository extends ServiceEntityRepository
parent::__construct($registry, Article::class); parent::__construct($registry, Article::class);
} }
/**
* Search articles by title, content, and summary using database LIKE queries
*/
public function searchArticles(string $query, int $limit = 12, int $offset = 0): array
{
$qb = $this->createQueryBuilder('a');
$searchTerms = explode(' ', trim($query));
$conditions = $qb->expr()->orX();
foreach ($searchTerms as $index => $term) {
$term = trim($term);
if (empty($term)) {
continue;
}
$paramName = 'term' . $index;
$termCondition = $qb->expr()->orX(
$qb->expr()->like('a.title', ':' . $paramName),
$qb->expr()->like('a.content', ':' . $paramName),
$qb->expr()->like('a.summary', ':' . $paramName)
);
$conditions->add($termCondition);
$qb->setParameter($paramName, '%' . $term . '%');
}
return $qb
->where($conditions)
->andWhere('a.content IS NOT NULL')
->andWhere('LENGTH(a.content) > 250') // Only articles with substantial content
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Find articles by multiple slugs
*/
public function findBySlugsCriteria(array $slugs): array
{
if (empty($slugs)) {
return [];
}
return $this->createQueryBuilder('a')
->where('a.slug IN (:slugs)')
->setParameter('slugs', $slugs)
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find articles by author's public key
*/
public function findByPubkey(string $pubkey, int $limit = 25): array
{
return $this->createQueryBuilder('a')
->where('a.pubkey = :pubkey')
->setParameter('pubkey', $pubkey)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
} }

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

@ -2,9 +2,7 @@
namespace App\Twig\Components\Organisms; namespace App\Twig\Components\Organisms;
use Elastica\Query; use App\Repository\ArticleRepository;
use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Event\Event; use swentel\nostr\Event\Event;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@ -19,7 +17,7 @@ final class FeaturedList
public function __construct( public function __construct(
private readonly CacheInterface $redisCache, private readonly CacheInterface $redisCache,
private readonly FinderInterface $finder) private readonly ArticleRepository $articleRepository)
{ {
} }
@ -44,37 +42,39 @@ final class FeaturedList
$parts = explode(':', $tag[1], 3); $parts = explode(':', $tag[1], 3);
$slugs[] = end($parts); $slugs[] = end($parts);
if (count($slugs) >= 5) { if (count($slugs) >= 5) {
break; // Limit to 4 items break; // Limit to 5 items
} }
} }
} }
$termsQuery = new Terms('slug', array_values($slugs)); // Use database query instead of Elasticsearch
$query = new Query($termsQuery); if (!empty($slugs)) {
$query->setSize(200); // Set size to exceed the number of articles we expect $articles = $this->articleRepository->findBySlugsCriteria($slugs);
$articles = $this->finder->find($query);
// Create a map of slug => item // Create a map of slug => item to get the latest version of each
$slugMap = []; $slugMap = [];
foreach ($articles as $article) { foreach ($articles as $article) {
$slug = $article->getSlug(); $slug = $article->getSlug();
if ($slug !== '') { if ($slug !== '') {
if (!isset($slugMap[$slug])) { if (!isset($slugMap[$slug])) {
$slugMap[$slug] = $article; $slugMap[$slug] = $article;
} elseif ($article->getCreatedAt() > $slugMap[$slug]->getCreatedAt()) { } elseif ($article->getCreatedAt() > $slugMap[$slug]->getCreatedAt()) {
$slugMap[$slug] = $article; $slugMap[$slug] = $article;
}
} }
} }
}
// Build ordered list based on original slugs order // Build ordered list based on original slugs order
$orderedList = []; $orderedList = [];
foreach ($slugs as $slug) { foreach ($slugs as $slug) {
if (isset($slugMap[$slug])) { if (isset($slugMap[$slug])) {
$orderedList[] = $slugMap[$slug]; $orderedList[] = $slugMap[$slug];
}
} }
}
$this->list = array_slice($orderedList, 0, 4); $this->list = array_slice($orderedList, 0, 4);
} else {
$this->list = [];
}
} }
} }

65
src/Twig/Components/SearchComponent.php

@ -3,7 +3,7 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Credits\Service\CreditsManager; use App\Credits\Service\CreditsManager;
use FOS\ElasticaBundle\Finder\FinderInterface; use App\Repository\ArticleRepository;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
@ -14,9 +14,6 @@ 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;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\MultiMatch;
#[AsLiveComponent] #[AsLiveComponent]
final class SearchComponent final class SearchComponent
@ -45,7 +42,7 @@ final class SearchComponent
private const string SESSION_QUERY_KEY = 'last_search_query'; private const string SESSION_QUERY_KEY = 'last_search_query';
public function __construct( public function __construct(
private readonly FinderInterface $finder, private readonly ArticleRepository $articleRepository,
private readonly CreditsManager $creditsManager, private readonly CreditsManager $creditsManager,
private readonly TokenStorageInterface $tokenStorage, private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
@ -123,60 +120,14 @@ final class SearchComponent
$this->creditsManager->spendCredits($this->npub, 1, 'search'); $this->creditsManager->spendCredits($this->npub, 1, 'search');
$this->credits--; $this->credits--;
// Create an optimized query using collapse correctly // Use database-based search instead of Elasticsearch
$mainQuery = new Query();
// Build multi-match query for searching across fields
$multiMatch = new MultiMatch();
$multiMatch->setQuery($this->query);
$multiMatch->setFields([
'title^3',
'summary^2',
'content^1.5',
'topics'
]);
$multiMatch->setType(MultiMatch::TYPE_MOST_FIELDS);
$multiMatch->setFuzziness('AUTO');
$boolQuery = new BoolQuery();
$boolQuery->addMust($multiMatch);
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
// For text fields, we need to use a different approach
// Create a regexp query that matches content with at least 250 chars
// This is a simplification - actually matches content with enough words
$lengthFilter = new Query\QueryString();
$lengthFilter->setQuery('content:/.{250,}/');
// $boolQuery->addFilter($lengthFilter);
$mainQuery->setQuery($boolQuery);
// Use the collapse field directly in the array format
// This fixes the [collapse] failed to parse field [inner_hits] error
$mainQuery->setParam('collapse', [
'field' => 'slug',
'inner_hits' => [
'name' => 'latest_articles',
'size' => 1 // Show more related articles
]
]);
// Reduce the minimum score threshold to include more results
$mainQuery->setMinScore(0.1); // Lower minimum score
// Sort by score and createdAt
$mainQuery->setSort([
'_score' => ['order' => 'desc'],
'createdAt' => ['order' => 'desc']
]);
// Add pagination
$offset = ($this->page - 1) * $this->resultsPerPage; $offset = ($this->page - 1) * $this->resultsPerPage;
$mainQuery->setFrom($offset); $results = $this->articleRepository->searchArticles(
$mainQuery->setSize($this->resultsPerPage); $this->query,
$this->resultsPerPage,
$offset
);
// Execute the search
$results = $this->finder->find($mainQuery);
$this->logger->info('Search results count: ' . count($results)); $this->logger->info('Search results count: ' . count($results));
$this->logger->info('Search results: ', ['results' => $results]); $this->logger->info('Search results: ', ['results' => $results]);

14
src/Util/IndexableArticleChecker.php

@ -1,14 +0,0 @@
<?php
namespace App\Util;
use App\Entity\Article;
use App\Enum\IndexStatusEnum;
class IndexableArticleChecker
{
public static function isIndexable(Article $article): bool
{
return $article->getIndexStatus() !== IndexStatusEnum::DO_NOT_INDEX;
}
}
Loading…
Cancel
Save