57 changed files with 1871 additions and 385 deletions
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
FROM php:8.2-cli |
||||
|
||||
# Install cron and Redis PHP extension dependencies |
||||
RUN apt-get update && apt-get install -y \ |
||||
cron \ |
||||
libzip-dev \ |
||||
libicu-dev \ |
||||
libpq-dev \ |
||||
libonig-dev |
||||
|
||||
# Install Redis PHP extension |
||||
RUN pecl install redis \ |
||||
&& docker-php-ext-enable redis |
||||
|
||||
RUN docker-php-ext-install pdo pdo_pgsql |
||||
|
||||
|
||||
# Set working directory |
||||
WORKDIR /var/www/html |
||||
|
||||
# Install Symfony CLI tools (optional) |
||||
# RUN curl -sS https://get.symfony.com/cli/installer | bash |
||||
|
||||
# Copy cron and script |
||||
COPY crontab /etc/cron.d/app-cron |
||||
COPY index_articles.sh /index_articles.sh |
||||
|
||||
# Set permissions |
||||
RUN chmod 0644 /etc/cron.d/app-cron && \ |
||||
chmod +x /index_articles.sh |
||||
|
||||
# Apply cron job |
||||
RUN crontab /etc/cron.d/app-cron |
||||
|
||||
# Run cron in the foreground |
||||
CMD ["cron", "-f"] |
||||
@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
|
||||
|
||||
# 🕒 Cron Job Container |
||||
|
||||
This folder contains the Docker configuration to run scheduled Symfony commands via cron inside a separate container. |
||||
|
||||
- Run Symfony console commands periodically using a cron schedule (e.g. every 6 hours) |
||||
- Decouple scheduled jobs from the main PHP/FPM container |
||||
- Easily manage and test cron execution in a Dockerized Symfony project |
||||
|
||||
--- |
||||
|
||||
## Build & Run |
||||
|
||||
1. **Build the cron image** |
||||
From the project root: |
||||
```bash |
||||
docker-compose build cron |
||||
``` |
||||
|
||||
2. **Start the cron container** |
||||
```bash |
||||
docker-compose up -d cron |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Cron Schedule |
||||
|
||||
The default cron schedule is set to run **every 6 hours**: |
||||
|
||||
```cron |
||||
0 */6 * * * root /run_commands.sh >> /var/log/cron.log 2>&1 |
||||
``` |
||||
|
||||
To customize the schedule, edit the `crontab` file and rebuild the container. |
||||
|
||||
--- |
||||
|
||||
## Testing & Debugging |
||||
|
||||
### Manually test the command runner |
||||
|
||||
You can run the script manually to check behavior without waiting for the cron trigger: |
||||
|
||||
```bash |
||||
docker-compose exec cron /run_commands.sh |
||||
``` |
||||
|
||||
### Check the cron output log |
||||
|
||||
```bash |
||||
docker-compose exec cron tail -f /var/log/cron.log |
||||
``` |
||||
|
||||
### Shell into the cron container |
||||
|
||||
```bash |
||||
docker-compose exec cron bash |
||||
``` |
||||
|
||||
Once inside, you can: |
||||
- Check crontab entries: `crontab -l` |
||||
- Manually trigger cron: `cron` or `cron -f` (in another session) |
||||
|
||||
--- |
||||
|
||||
## Customization |
||||
|
||||
- **Add/Remove Symfony Commands:** |
||||
Edit `run_commands.sh` to include the commands you want to run. |
||||
|
||||
- **Change Schedule:** |
||||
Edit `crontab` using standard cron syntax. |
||||
|
||||
- **Logging:** |
||||
Logs are sent to `/var/log/cron.log` inside the container. |
||||
|
||||
--- |
||||
|
||||
## Rebuilding After Changes |
||||
|
||||
If you modify the `crontab` or `run_commands.sh`, make sure to rebuild: |
||||
|
||||
```bash |
||||
docker-compose build cron |
||||
docker-compose up -d cron |
||||
``` |
||||
|
||||
--- |
||||
|
||||
## Notes |
||||
|
||||
- Symfony project source is mounted at `/var/www/html` via volume. |
||||
- Make sure your commands do **not rely on services** (like `php-fpm`) that are not running in this container. |
||||
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
0 */6 * * * /index_articles.sh >> /var/log/cron.log 2>&1 |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash |
||||
set -e |
||||
|
||||
# Run Symfony commands sequentially |
||||
/usr/local/bin/php /var/www/html/bin/console articles:get |
||||
/usr/local/bin/php /var/www/html/bin/console articles:qa |
||||
/usr/local/bin/php /var/www/html/bin/console articles:index |
||||
/usr/local/bin/php /var/www/html/bin/console articles:indexed |
||||
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace DoctrineMigrations; |
||||
|
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Doctrine\Migrations\AbstractMigration; |
||||
|
||||
/** |
||||
* Auto-generated Migration: Please modify to your needs! |
||||
*/ |
||||
final class Version20250509100039 extends AbstractMigration |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return ''; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
// this up() migration is auto-generated, please modify it to your needs |
||||
$this->addSql(<<<'SQL' |
||||
DROP TABLE sessions |
||||
SQL); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql(<<<'SQL' |
||||
CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL, sess_data BYTEA NOT NULL, sess_lifetime INT NOT NULL, sess_time INT NOT NULL, PRIMARY KEY(sess_id)) |
||||
SQL); |
||||
$this->addSql(<<<'SQL' |
||||
CREATE INDEX sess_lifetime_idx ON sessions (sess_lifetime) |
||||
SQL); |
||||
} |
||||
} |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace DoctrineMigrations; |
||||
|
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Doctrine\Migrations\AbstractMigration; |
||||
|
||||
/** |
||||
* Auto-generated Migration: Please modify to your needs! |
||||
*/ |
||||
final class Version20250509100408 extends AbstractMigration |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return ''; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
// this up() migration is auto-generated, please modify it to your needs |
||||
$this->addSql(<<<'SQL' |
||||
CREATE TABLE credit_transaction (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, npub VARCHAR(64) NOT NULL, amount INT NOT NULL, type VARCHAR(16) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, reason VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id)) |
||||
SQL); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
// this down() migration is auto-generated, please modify it to your needs |
||||
$this->addSql(<<<'SQL' |
||||
DROP TABLE credit_transaction |
||||
SQL); |
||||
} |
||||
} |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Enum\IndexStatusEnum; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
|
||||
#[AsCommand(name: 'db:cleanup', description: 'Remove articles with do_not_index rating')] |
||||
class DatabaseCleanupCommand extends Command |
||||
{ |
||||
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
|
||||
$repository = $this->entityManager->getRepository(Article::class); |
||||
$items = $repository->findBy(['indexStatus' => IndexStatusEnum::DO_NOT_INDEX]); |
||||
|
||||
if (empty($items)) { |
||||
$output->writeln('<info>No items found.</info>'); |
||||
return Command::SUCCESS; |
||||
} |
||||
|
||||
foreach ($items as $item) { |
||||
$this->entityManager->remove($item); |
||||
} |
||||
|
||||
$this->entityManager->flush(); |
||||
|
||||
$output->writeln('<comment>Deleted ' . count($items) . ' items.</comment>'); |
||||
|
||||
|
||||
return Command::SUCCESS; |
||||
} |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Entity\User; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputArgument; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
|
||||
#[AsCommand( |
||||
name: 'user:elevate', |
||||
description: 'Assign a role to user' |
||||
)] |
||||
class ElevateUserCommand extends Command |
||||
{ |
||||
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function configure(): void |
||||
{ |
||||
$this |
||||
->addArgument('arg1', InputArgument::REQUIRED, 'User npub') |
||||
->addArgument('arg2', InputArgument::REQUIRED, 'Role to set'); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$npub = $input->getArgument('arg1'); |
||||
$role = $input->getArgument('arg2'); |
||||
if (!str_starts_with($role, 'ROLE_')) { |
||||
return Command::INVALID; |
||||
} |
||||
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['npub' => $npub]); |
||||
if (!$user) { |
||||
return Command::FAILURE; |
||||
} |
||||
|
||||
$user->addRole($role); |
||||
$this->entityManager->persist($user); |
||||
$this->entityManager->flush(); |
||||
|
||||
return Command::SUCCESS; |
||||
} |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
<?php |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Service\NostrClient; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
|
||||
#[AsCommand( |
||||
name: 'articles:get', |
||||
description: 'Pull articles from a default relay', |
||||
)] |
||||
class GetArticlesCommand extends Command |
||||
{ |
||||
public function __construct(private readonly NostrClient $nostrClient) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
|
||||
$this->nostrClient->getLongFormContent(); |
||||
|
||||
return Command::SUCCESS; |
||||
} |
||||
} |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Enum\IndexStatusEnum; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
|
||||
#[AsCommand(name: 'articles:index', description: 'Persist selected articles to Elastic')] |
||||
class IndexArticlesCommand extends Command |
||||
{ |
||||
private const BATCH_SIZE = 100; // Define batch size |
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly ObjectPersisterInterface $itemPersister) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
|
||||
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]); |
||||
|
||||
$batchCount = 0; |
||||
$processedCount = 0; |
||||
|
||||
foreach ($articles as $item) { |
||||
$batchCount++; |
||||
|
||||
// Collect batch of entities for indexing |
||||
$batchItems[] = $item; |
||||
|
||||
// Process batch when limit is reached |
||||
if ($batchCount >= self::BATCH_SIZE) { |
||||
$this->flushAndPersistBatch($batchItems); |
||||
$processedCount += $batchCount; |
||||
$batchCount = 0; |
||||
$batchItems = []; |
||||
} |
||||
} |
||||
|
||||
// Process any remaining items |
||||
if (!empty($batchItems)) { |
||||
$this->flushAndPersistBatch($batchItems); |
||||
$processedCount += count($batchItems); |
||||
} |
||||
|
||||
$output->writeln("$processedCount items indexed in Elasticsearch."); |
||||
return Command::SUCCESS; |
||||
} |
||||
|
||||
private function flushAndPersistBatch(array $items): void |
||||
{ |
||||
// Persist batch to Elasticsearch |
||||
$this->itemPersister->replaceMany($items); |
||||
} |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Enum\IndexStatusEnum; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
|
||||
#[AsCommand(name: 'articles:indexed', description: 'Mark articles as indexed after populating')] |
||||
class MarkAsIndexedCommand extends Command |
||||
{ |
||||
public function __construct(private readonly EntityManagerInterface $entityManager) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::TO_BE_INDEXED]); |
||||
$count = 0; |
||||
foreach ($articles as $article) { |
||||
if ($article instanceof Article) { |
||||
$count += 1; |
||||
$article->setIndexStatus(IndexStatusEnum::INDEXED); |
||||
$this->entityManager->persist($article); |
||||
} |
||||
} |
||||
|
||||
$this->entityManager->flush(); |
||||
|
||||
$output->writeln($count . ' articles marked as indexed successfully.'); |
||||
|
||||
return Command::SUCCESS; |
||||
} |
||||
} |
||||
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use swentel\nostr\Event\Event; |
||||
use swentel\nostr\Sign\Sign; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputArgument; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
use Symfony\Component\Finder\Finder; |
||||
use Symfony\Component\Yaml\Yaml; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
|
||||
#[AsCommand(name: 'app:yaml_to_nostr', description: 'Traverses folders, converts YAML files to JSON using object mapping, and saves the result in Redis cache.')] |
||||
class NostrEventFromYamlDefinitionCommand extends Command |
||||
{ |
||||
const private_key = 'nsec17ygfd40ckdwmrl4mzhnzzdr3c8j5kvnavgrct35hglha9ue396dslsterv'; |
||||
|
||||
public function __construct(private readonly CacheInterface $redisCache) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
|
||||
protected function configure(): void |
||||
{ |
||||
$this |
||||
->addArgument('folder', InputArgument::REQUIRED, 'The folder location to start scanning from.'); |
||||
} |
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$folder = $input->getArgument('folder'); |
||||
|
||||
// Use Symfony Finder to locate YAML files recursively |
||||
$finder = new Finder(); |
||||
$finder->files() |
||||
->in($folder) |
||||
->name('*.yaml') |
||||
->name('*.yml'); |
||||
|
||||
if (!$finder->hasResults()) { |
||||
$output->writeln('<comment>No YAML files found in the specified directory.</comment>'); |
||||
return Command::SUCCESS; |
||||
} |
||||
|
||||
foreach ($finder as $file) { |
||||
$filePath = $file->getRealPath(); |
||||
$output->writeln("<info>Processing file: $filePath</info>"); |
||||
$yamlContent = Yaml::parseFile($filePath); // This parses the YAML file |
||||
|
||||
try { |
||||
// Deserialize YAML content into an Event object |
||||
$event = new Event(); |
||||
$event->setKind(30040); |
||||
$event->setPublicKey('e00983324f38e8522ffc01d5c064727e43fe4c43d86a5c2a0e73290674e496f8'); |
||||
$tags = $yamlContent['tags']; |
||||
$event->setTags($tags); |
||||
|
||||
$signer = new Sign(); |
||||
$signer->signEvent($event, NostrEventFromYamlDefinitionCommand::private_key); |
||||
|
||||
// Save to cache |
||||
$slug = array_filter($tags, function ($tag) { |
||||
return ($tag[0] === 'd'); |
||||
}); |
||||
// Generate a Redis key |
||||
$cacheKey = 'magazine-' . $slug[0][1]; |
||||
$cacheItem = $this->redisCache->getItem($cacheKey); |
||||
$cacheItem->set($event); |
||||
$this->redisCache->save($cacheItem); |
||||
|
||||
$output->writeln("<info>Saved index.</info>"); |
||||
} catch (\Exception $e) { |
||||
$output->writeln("<error>Error deserializing YAML in file: $filePath. Message: {$e->getMessage()}</error>"); |
||||
continue; |
||||
} |
||||
} |
||||
|
||||
$output->writeln('<info>Conversion complete.</info>'); |
||||
return Command::SUCCESS; |
||||
} |
||||
} |
||||
@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Command; |
||||
|
||||
use App\Entity\Article; |
||||
use App\Enum\IndexStatusEnum; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use FOS\ElasticaBundle\Persister\ObjectPersister; |
||||
use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; |
||||
use Symfony\Component\Console\Attribute\AsCommand; |
||||
use Symfony\Component\Console\Command\Command; |
||||
use Symfony\Component\Console\Input\InputInterface; |
||||
use Symfony\Component\Console\Output\OutputInterface; |
||||
|
||||
#[AsCommand(name: 'articles:qa', description: 'Mark articles by quality and select which to index')] |
||||
class QualityCheckArticlesCommand extends Command |
||||
{ |
||||
public function __construct( |
||||
private readonly EntityManagerInterface $entityManager |
||||
) |
||||
{ |
||||
parent::__construct(); |
||||
} |
||||
protected function execute(InputInterface $input, OutputInterface $output): int |
||||
{ |
||||
$articles = $this->entityManager->getRepository(Article::class)->findBy(['indexStatus' => IndexStatusEnum::NOT_INDEXED]); |
||||
$count = 0; |
||||
foreach ($articles as $article) { |
||||
if ($this->meetsCriteria($article)) { |
||||
$count += 1; |
||||
$article->setIndexStatus(IndexStatusEnum::TO_BE_INDEXED); |
||||
} else { |
||||
$article->setIndexStatus(IndexStatusEnum::DO_NOT_INDEX); |
||||
} |
||||
$this->entityManager->persist($article); |
||||
} |
||||
|
||||
$this->entityManager->flush(); |
||||
|
||||
$output->writeln($count . ' articles marked for indexing successfully.'); |
||||
|
||||
return Command::SUCCESS; |
||||
} |
||||
|
||||
private function meetsCriteria(Article $article): bool |
||||
{ |
||||
$content = $article->getContent(); |
||||
|
||||
// No empty title |
||||
if (empty($article->getTitle()) || strtolower($article->getTitle()) === 'test') { |
||||
return false; |
||||
} |
||||
|
||||
// Do not index stacker news reposts |
||||
if (str_contains($content, 'originally posted at https://stacker.news')) { |
||||
return false; |
||||
} |
||||
|
||||
// Slug must not contain '/' and should not be empty |
||||
if (empty($article->getSlug()) || str_contains($article->getSlug(), '/')) { |
||||
return false; |
||||
} |
||||
|
||||
// Only index articles with more than 12 words |
||||
return str_word_count($article->getContent()) > 12; |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller\Administration; |
||||
|
||||
use App\Credits\Entity\CreditTransaction; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
|
||||
class CreditTransactionController extends AbstractController |
||||
{ |
||||
#[Route('/admin/transactions', name: 'admin_credit_transactions')] |
||||
public function index(EntityManagerInterface $em): Response |
||||
{ |
||||
$transactions = $em->getRepository(CreditTransaction::class)->findBy([], ['createdAt' => 'DESC']); |
||||
|
||||
return $this->render('admin/transactions.html.twig', [ |
||||
'transactions' => $transactions, |
||||
]); |
||||
} |
||||
} |
||||
@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
|
||||
<?php |
||||
|
||||
namespace App\Credits\Entity; |
||||
|
||||
use Doctrine\ORM\Mapping as ORM; |
||||
|
||||
#[ORM\Entity] |
||||
class CreditTransaction |
||||
{ |
||||
#[ORM\Id] |
||||
#[ORM\GeneratedValue] |
||||
#[ORM\Column(type: 'integer')] |
||||
private int $id; |
||||
|
||||
#[ORM\Column(length: 64)] |
||||
private string $npub; |
||||
|
||||
#[ORM\Column(type: 'integer')] |
||||
private int $amount; |
||||
|
||||
#[ORM\Column(length: 16)] |
||||
private string $type; // 'credit' or 'debit' |
||||
|
||||
#[ORM\Column(type: 'datetime')] |
||||
private \DateTime $createdAt; |
||||
|
||||
#[ORM\Column(nullable: true)] |
||||
private ?string $reason = null; |
||||
|
||||
public function __construct(string $npub, int $amount, string $type, ?string $reason = null) |
||||
{ |
||||
$this->npub = $npub; |
||||
$this->amount = $amount; |
||||
$this->type = $type; |
||||
$this->createdAt = new \DateTime(); |
||||
$this->reason = $reason; |
||||
} |
||||
|
||||
public function getId(): int |
||||
{ |
||||
return $this->id; |
||||
} |
||||
|
||||
public function setId(int $id): void |
||||
{ |
||||
$this->id = $id; |
||||
} |
||||
|
||||
public function getNpub(): string |
||||
{ |
||||
return $this->npub; |
||||
} |
||||
|
||||
public function setNpub(string $npub): void |
||||
{ |
||||
$this->npub = $npub; |
||||
} |
||||
|
||||
public function getAmount(): int |
||||
{ |
||||
return $this->amount; |
||||
} |
||||
|
||||
public function setAmount(int $amount): void |
||||
{ |
||||
$this->amount = $amount; |
||||
} |
||||
|
||||
public function getType(): string |
||||
{ |
||||
return $this->type; |
||||
} |
||||
|
||||
public function setType(string $type): void |
||||
{ |
||||
$this->type = $type; |
||||
} |
||||
|
||||
public function getCreatedAt(): \DateTime |
||||
{ |
||||
return $this->createdAt; |
||||
} |
||||
|
||||
public function setCreatedAt(\DateTime $createdAt): void |
||||
{ |
||||
$this->createdAt = $createdAt; |
||||
} |
||||
|
||||
public function getReason(): ?string |
||||
{ |
||||
return $this->reason; |
||||
} |
||||
|
||||
public function setReason(?string $reason): void |
||||
{ |
||||
$this->reason = $reason; |
||||
} |
||||
|
||||
} |
||||
|
||||
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
<?php |
||||
|
||||
namespace App\Credits\Service; |
||||
|
||||
use App\Credits\Entity\CreditTransaction; |
||||
use App\Credits\Util\RedisCreditStore; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Psr\Cache\InvalidArgumentException; |
||||
|
||||
readonly class CreditsManager |
||||
{ |
||||
public function __construct( |
||||
private RedisCreditStore $redisStore, |
||||
private EntityManagerInterface $em |
||||
) {} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function getBalance(string $npub): int |
||||
{ |
||||
return $this->redisStore->getBalance($npub); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function resetBalance(string $npub): int |
||||
{ |
||||
return $this->redisStore->resetBalance($npub); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function addCredits(string $npub, int $amount, ?string $reason = null): void |
||||
{ |
||||
$this->redisStore->addCredits($npub, $amount); |
||||
|
||||
$tx = new CreditTransaction($npub, $amount, 'credit', $reason); |
||||
$this->em->persist($tx); |
||||
$this->em->flush(); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function canAfford(string $npub, int $cost): bool |
||||
{ |
||||
return $this->getBalance($npub) >= $cost; |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function spendCredits(string $npub, int $cost, ?string $reason = null): void |
||||
{ |
||||
if (!$this->canAfford($npub, $cost)) { |
||||
throw new \RuntimeException("Insufficient credits for $npub"); |
||||
} |
||||
|
||||
$this->redisStore->spendCredits($npub, $cost); |
||||
|
||||
$tx = new CreditTransaction($npub, $cost, 'debit', $reason); |
||||
$this->em->persist($tx); |
||||
$this->em->flush(); |
||||
} |
||||
} |
||||
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
<?php |
||||
|
||||
namespace App\Credits\Util; |
||||
|
||||
use App\Credits\Entity\CreditTransaction; |
||||
use Doctrine\ORM\EntityManagerInterface; |
||||
use Psr\Cache\InvalidArgumentException; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
|
||||
readonly class RedisCreditStore |
||||
{ |
||||
public function __construct( |
||||
private CacheInterface $creditsCache, |
||||
private EntityManagerInterface $em |
||||
) {} |
||||
|
||||
private function key(string $npub): string |
||||
{ |
||||
return 'credits_' . $npub; |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function resetBalance(string $npub): int |
||||
{ |
||||
$this->creditsCache->delete($this->key($npub)); |
||||
|
||||
// Fetch all transactions for the given npub |
||||
$transactions = $this->em->getRepository(CreditTransaction::class) |
||||
->findBy(['npub' => $npub]); |
||||
|
||||
// Initialize the balance |
||||
$balance = 0; |
||||
|
||||
// Calculate the final balance based on the transactions |
||||
foreach ($transactions as $tx) { |
||||
if ($tx->getType() === 'credit') { |
||||
$balance += $tx->getAmount(); |
||||
} elseif ($tx->getType() === 'debit') { |
||||
$balance -= $tx->getAmount(); |
||||
} |
||||
} |
||||
|
||||
// Write the calculated balance into the Redis cache |
||||
$item = $this->creditsCache->getItem($this->key($npub)); |
||||
$item->set($balance); |
||||
$this->creditsCache->save($item); |
||||
|
||||
return $balance; |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function getBalance(string $npub): int |
||||
{ |
||||
// Use cache pool to fetch the credit balance |
||||
return $this->creditsCache->get($this->key($npub), function () use ($npub) { |
||||
return $this->resetBalance($npub); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function addCredits(string $npub, int $amount): void |
||||
{ |
||||
$currentBalance = $this->getBalance($npub); |
||||
$item = $this->creditsCache->getItem($this->key($npub)); |
||||
$balance = $currentBalance + $amount; |
||||
$item->set($balance); |
||||
$this->creditsCache->save($item); |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function spendCredits(string $npub, int $amount): void |
||||
{ |
||||
$currentBalance = $this->getBalance($npub); |
||||
$item = $this->creditsCache->getItem($this->key($npub)); |
||||
if ($currentBalance < $amount) { |
||||
throw new \RuntimeException('Insufficient credits'); |
||||
} |
||||
$balance = $currentBalance - $amount; |
||||
$item->set($balance); |
||||
$this->creditsCache->save($item); |
||||
} |
||||
} |
||||
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components; |
||||
|
||||
use App\Credits\Service\CreditsManager; |
||||
use Psr\Cache\InvalidArgumentException; |
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
||||
use Symfony\UX\LiveComponent\ComponentToolsTrait; |
||||
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||
|
||||
#[AsLiveComponent] |
||||
final class GetCreditsComponent |
||||
{ |
||||
use DefaultActionTrait; |
||||
use ComponentToolsTrait; |
||||
|
||||
public function __construct( |
||||
private readonly CreditsManager $creditsManager, |
||||
private readonly TokenStorageInterface $tokenStorage) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
#[LiveAction] |
||||
public function grantVoucher(): void |
||||
{ |
||||
$npub = $this->tokenStorage->getToken()?->getUserIdentifier(); |
||||
if ($npub) { |
||||
$this->creditsManager->addCredits($npub, 5, 'voucher'); |
||||
} |
||||
|
||||
// Dispatch event to notify parent |
||||
$this->emit('creditsAdded', [ |
||||
'credits' => 5, |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Molecules; |
||||
|
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
final class CategoryLink |
||||
{ |
||||
public string $title; |
||||
public string $slug; |
||||
|
||||
public function __construct(private CacheInterface $redisCache) |
||||
{ |
||||
} |
||||
|
||||
public function mount($coordinate): void |
||||
{ |
||||
if (key_exists(1, $coordinate)) { |
||||
$parts = explode(':', $coordinate[1]); |
||||
$this->slug = $parts[2]; |
||||
$cat = $this->redisCache->get('magazine-' . $parts[2], function (){ |
||||
return null; |
||||
}); |
||||
|
||||
$tags = $cat->getTags(); |
||||
|
||||
$title = array_filter($tags, function($tag) { |
||||
return ($tag[0] === 'title'); |
||||
}); |
||||
|
||||
$this->title = $title[array_key_first($title)][1]; |
||||
} else { |
||||
dump($coordinate);die(); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Organisms; |
||||
|
||||
use App\Service\NostrClient; |
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
final class Comments |
||||
{ |
||||
public array $list = []; |
||||
|
||||
public function __construct(private readonly NostrClient $nostrClient) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* @throws \Exception |
||||
*/ |
||||
public function mount($current): void |
||||
{ |
||||
// fetch comments, kind 1111 |
||||
$this->list = $this->nostrClient->getComments($current); |
||||
} |
||||
} |
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Organisms; |
||||
|
||||
use Elastica\Query\MatchQuery; |
||||
use FOS\ElasticaBundle\Finder\FinderInterface; |
||||
use Psr\Cache\InvalidArgumentException; |
||||
use swentel\nostr\Event\Event; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||
|
||||
#[AsTwigComponent] |
||||
final class FeaturedList |
||||
{ |
||||
public $category; |
||||
public string $title; |
||||
public array $list = []; |
||||
|
||||
public function __construct(private readonly CacheInterface $redisCache, private readonly FinderInterface $finder) |
||||
{ |
||||
} |
||||
|
||||
/** |
||||
* @throws InvalidArgumentException |
||||
*/ |
||||
public function mount($category): void |
||||
{ |
||||
$parts = explode(':', $category[1]); |
||||
/** @var Event $catIndex */ |
||||
$catIndex = $this->redisCache->get('magazine-' . $parts[2], function (){ |
||||
throw new \Exception('Not found'); |
||||
}); |
||||
|
||||
foreach ($catIndex->getTags() as $tag) { |
||||
if ($tag[0] === 'title') { |
||||
$this->title = $tag[1]; |
||||
} |
||||
if ($tag[0] === 'a') { |
||||
$parts = explode(':', $tag[1]); |
||||
if (count($parts) === 3) { |
||||
$fieldQuery = new MatchQuery(); |
||||
$fieldQuery->setFieldQuery('slug', $parts[2]); |
||||
$res = $this->finder->find($fieldQuery); |
||||
$this->list[] = $res[0]; |
||||
} |
||||
} |
||||
if (count($this->list) > 3) { |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Twig; |
||||
|
||||
use Twig\Extension\AbstractExtension; |
||||
use Twig\TwigFunction; |
||||
use Twig\TwigFilter; |
||||
|
||||
class Filters extends AbstractExtension |
||||
{ |
||||
public function getFilters(): array |
||||
{ |
||||
return [ |
||||
new TwigFilter('shortenNpub', [$this, 'shortenNpub']), |
||||
]; |
||||
} |
||||
|
||||
public function shortenNpub(string $npub): string |
||||
{ |
||||
return substr($npub, 0, 8) . '…' . substr($npub, -4); |
||||
} |
||||
} |
||||
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
<?php |
||||
|
||||
namespace App\Util\CommonMark\ImagesExtension; |
||||
|
||||
use League\CommonMark\Environment\EnvironmentBuilderInterface; |
||||
use League\CommonMark\Extension\ExtensionInterface; |
||||
|
||||
class RawImageLinkExtension implements ExtensionInterface |
||||
{ |
||||
public function register(EnvironmentBuilderInterface $environment): void |
||||
{ |
||||
$environment->addInlineParser(new RawImageLinkParser()); |
||||
} |
||||
} |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
<?php |
||||
|
||||
namespace App\Util\CommonMark\ImagesExtension; |
||||
|
||||
use League\CommonMark\Node\Block\Paragraph; |
||||
use League\CommonMark\Parser\Inline\InlineParserInterface; |
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\Image; |
||||
use League\CommonMark\Extension\CommonMark\Node\Inline\SoftBreak; |
||||
use League\CommonMark\Parser\Inline\InlineParserMatch; |
||||
use League\CommonMark\Parser\InlineParserContext; |
||||
|
||||
class RawImageLinkParser implements InlineParserInterface |
||||
{ |
||||
public function getMatchDefinition(): InlineParserMatch |
||||
{ |
||||
// Match URLs ending with an image extension |
||||
return InlineParserMatch::regex('https?:\/\/[^\s]+?\.(?:jpg|jpeg|png|gif|webp)(?=\s|$)'); |
||||
} |
||||
|
||||
public function parse(InlineParserContext $inlineContext): bool |
||||
{ |
||||
$cursor = $inlineContext->getCursor(); |
||||
$match = $inlineContext->getFullMatch(); |
||||
// Create an <img> element instead of a text link |
||||
$image = new Image($match, ''); |
||||
$paragraph = new Paragraph(); |
||||
$paragraph->appendChild($image); |
||||
$inlineContext->getContainer()->appendChild($paragraph); |
||||
|
||||
// Advance the cursor to consume the matched part (important!) |
||||
$cursor->advanceBy(strlen($match)); |
||||
|
||||
return true; |
||||
} |
||||
} |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% block title %}Credit Transactions{% endblock %} |
||||
|
||||
{% block body %} |
||||
<h1>Credit Transactions</h1> |
||||
|
||||
<table> |
||||
<thead> |
||||
<tr> |
||||
<th>ID</th> |
||||
<th>NPub</th> |
||||
<th>Type</th> |
||||
<th>Amount</th> |
||||
<th>Reason</th> |
||||
<th>Timestamp</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for tx in transactions %} |
||||
<tr> |
||||
<td>{{ tx.id }}</td> |
||||
<td><span title="{{ tx.npub }}">{{ tx.npub|shortenNpub }}</span></td> |
||||
<td>{{ tx.type }}</td> |
||||
<td>{{ tx.amount }}</td> |
||||
<td>{{ tx.reason ?: '—' }}</td> |
||||
<td>{{ tx.createdAt|date('Y-m-d H:i:s') }}</td> |
||||
</tr> |
||||
{% else %} |
||||
<tr> |
||||
<td colspan="6">No transactions found.</td> |
||||
</tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% endblock %} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
<div {{ attributes }}> |
||||
<button data-action="live#action" |
||||
data-live-action-param="grantVoucher"> |
||||
Get 5 Free Credits |
||||
</button> |
||||
</div> |
||||
@ -1,28 +1,27 @@
@@ -1,28 +1,27 @@
|
||||
{% if article is defined %} |
||||
<{{ tag }} {{ attributes }}> |
||||
<div class="card"> |
||||
<div class="metadata"> |
||||
{% if category %} |
||||
<small>{{ category }}</small> |
||||
{% else %} |
||||
<p>by <twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></p> |
||||
<small>{{ article.createdAt|date('F j Y') }}</small> |
||||
{% endif %} |
||||
</div> |
||||
<a href="{{ path('article-slug', {slug: article.slug}) }}"> |
||||
<div class="card-header"> |
||||
{% if category %}<small class="text-uppercase">{{ category }}</small>{% endif %} |
||||
{% if article.image %} |
||||
<img src="{{ article.image }}" alt=""> |
||||
<img src="{{ article.image }}" alt="Cover image for {{ article.title }}" onerror="this.style.display='none';" > |
||||
{% endif %} |
||||
</div> |
||||
<div class="card-body"> |
||||
{# <small>{{ article.createdAt|date('F j') }}</small>#} |
||||
<h2 class="card-title">{{ article.title }}</h2> |
||||
<p class="lede"> |
||||
{{ article.summary }} |
||||
</p> |
||||
</div> |
||||
</{{ tag }}> |
||||
{#<div class="card card-footer">#} |
||||
{# <a href="{{ path('author-profile', { npub: article.pubkey })}}"><twig:Molecules:UserFromNpub pubkey="{{ article.pubkey }}" /></a>#} |
||||
{#</div>#} |
||||
{% endif %} |
||||
{% if user is defined %} |
||||
<{{ tag }} {{ attributes }}> |
||||
<div class="card-body"> |
||||
<h3 class="card-title">{{ user.name }}</h3> |
||||
<p>{{ user.about }}</p> |
||||
</a> |
||||
<div class="card-footer"></div> |
||||
</div> |
||||
</{{ tag }}> |
||||
{% endif %} |
||||
|
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
<a {% if path('magazine-category', { 'slug' : slug }) in app.request.uri %}class="active"{% endif %} |
||||
href="{{ path('magazine-category', { 'slug' : slug }) }}"> |
||||
{{ title }} |
||||
</a> |
||||
@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
|
||||
{% if user %} |
||||
<twig:Atoms:NameOrNpub :author="user" /> |
||||
<a href="{{ path('author-profile', { npub: npub })}}"><twig:Atoms:NameOrNpub :author="user" /></a> |
||||
{% else %} |
||||
<span>{{ npub }}</span> |
||||
<a href="{{ path('author-profile', { npub: npub })}}"><span>{{ npub|shortenNpub }}</span></a> |
||||
{% endif %} |
||||
|
||||
@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
|
||||
<div {{ attributes }}> |
||||
{% for item in list %} |
||||
{% if item.slug is not empty %} |
||||
<twig:Molecules:Card class="card" :article="item" tag="a" href="{{ path('article-slug', {slug: item.slug}) }}" ></twig:Molecules:Card> |
||||
<twig:Molecules:Card :article="item"></twig:Molecules:Card> |
||||
{% endif %} |
||||
{% endfor %} |
||||
</div> |
||||
|
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
<div class="comments"> |
||||
{% for item in list %} |
||||
<div class="comment"> |
||||
<span>{{ item.content }}</span> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
<div> |
||||
{% if list %} |
||||
<div class="featured-cat hidden"> |
||||
<small><b>{{ title }}</b></small> |
||||
</div> |
||||
<div {{ attributes }}> |
||||
<div> |
||||
{% set feature = list[0] %} |
||||
<div class="card"> |
||||
<a href="{{ path('article-slug', {slug: feature.slug}) }}"> |
||||
<div class="card-header"> |
||||
{% if feature.image %} |
||||
<img src="{{ feature.image }}" alt="Cover image for {{ feature.title }}"> |
||||
{% endif %} |
||||
</div> |
||||
<div class="card-body"> |
||||
<h2 class="card-title">{{ feature.title }}</h2> |
||||
<p class="lede truncate"> |
||||
{{ feature.summary }} |
||||
</p> |
||||
</div> |
||||
</a> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
{% for item in list %} |
||||
{% if item != feature %} |
||||
<div class="card"> |
||||
<a href="{{ path('article-slug', {slug: item.slug}) }}"> |
||||
<div class="card-body"> |
||||
<h2 class="card-title">{{ item.title }}</h2> |
||||
<p class="lede truncate"> |
||||
{{ item.summary }} |
||||
</p> |
||||
</div> |
||||
</a> |
||||
</div> |
||||
{% endif %} |
||||
{% endfor %} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
</div> |
||||
Loading…
Reference in new issue