Browse Source

Cache, routes and such

imwald
Nuša Pukšič 1 month ago
parent
commit
fd96f60700
  1. 84
      assets/app.js
  2. 16
      assets/styles/03-components/button.css
  3. 8
      assets/styles/03-components/dropdown.css
  4. 125
      assets/styles/04-pages/discover.css
  5. 5
      config/packages/cache.yaml
  6. 2
      config/packages/framework.yaml
  7. 13
      config/services.yaml
  8. 2
      src/Controller/ArticleController.php
  9. 118
      src/Controller/DefaultController.php
  10. 61
      src/Controller/ForumController.php
  11. 2
      src/Controller/MediaDiscoveryController.php
  12. 6
      src/Controller/SearchController.php
  13. 71
      src/Service/NostrClient.php
  14. 52
      src/Service/RedisCacheService.php
  15. 119
      src/Session/GracefulSessionHandler.php
  16. 68
      src/Twig/Components/SearchComponent.php
  17. 2
      sync-strfry.sh
  18. 4
      templates/components/Atoms/ForumAside.html.twig
  19. 28
      templates/components/SearchComponent.html.twig
  20. 4
      templates/forum/index.html.twig
  21. 32
      templates/forum/main_topic.html.twig
  22. 8
      templates/layout.html.twig
  23. 57
      templates/pages/discover.html.twig
  24. 11
      templates/pages/search.html.twig

84
assets/app.js

@ -45,6 +45,7 @@ import './styles/04-pages/analytics.css'; @@ -45,6 +45,7 @@ import './styles/04-pages/analytics.css';
import './styles/04-pages/author-media.css';
import './styles/04-pages/forum.css';
import './styles/04-pages/highlights.css';
import './styles/04-pages/discover.css';
// 05 - Utilities (last for highest specificity)
import './styles/05-utilities/utilities.css';
@ -54,16 +55,86 @@ console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); @@ -54,16 +55,86 @@ console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');
import 'katex/dist/katex.min.css';
import renderMathInElement from 'katex/dist/contrib/auto-render.mjs';
// Detect math blocks in text content while avoiding common currency patterns
function hasRealMath(text) {
if (!text) return false;
const t = text;
// Skip plain currency-like $123.45 or $ 1,234 patterns
const currency = /\$\s*[\d.,]+(?:\b|\s)/g;
// Detect LaTeX-style delimiters with likely math content inside
const inlineDollar = /\$(?<body>(?:[^$\\]|\\.)+)\$/g; // $ ... $
const doubleDollar = /\$\$(?<body>(?:[^$\\]|\\.)+)\$\$/g; // $$ ... $$
const parenMath = /\\\((?<body>(?:[^\\]|\\.)+)\\\)/g; // \( ... \)
const bracketMath = /\\\[(?<body>(?:[^\\]|\\.)+)\\\]/g; // \[ ... \]
// Heuristic: content contains typical math tokens (command, ^, _, { }, fractions)
const looksMathy = (s) => /\\[a-zA-Z]+|[\^_]|[{}]|\\frac|\\sum|\\int|\\lim|\\alpha|\\beta|\\gamma|\\rightarrow|\\matrm|\\mathrm|\\mathbb|\\mathbf/.test(s);
// If text has only currency-like $... patterns and no delimiters, don't mark as math
const hasNonCurrencyDollar = (() => {
let m;
// Any $...$ that isn't just numbers
const dollarAny = /\$(?:[^$]+)\$/g;
while ((m = dollarAny.exec(t)) !== null) {
const inner = m[0].slice(1, -1);
if (!/^\s*[\d.,]+\s*$/.test(inner) && looksMathy(inner)) return true;
}
return false;
})();
// Check each delimiter type
const checkDelim = (regex) => {
let m;
while ((m = regex.exec(t)) !== null) {
const inner = m.groups?.body ?? '';
if (looksMathy(inner)) return true;
}
return false;
};
if (checkDelim(doubleDollar)) return true;
if (checkDelim(parenMath)) return true;
if (checkDelim(bracketMath)) return true;
if (hasNonCurrencyDollar) return true;
// Also allow $...$ where the inner isn't currency and includes letters with math markers
const inlineDollarLoose = /\$(?<body>[^$]+)\$/g;
let m;
while ((m = inlineDollarLoose.exec(t)) !== null) {
const inner = m.groups?.body ?? '';
if (!/^\s*[\d.,]+\s*$/.test(inner) && /[A-Za-z]/.test(inner) && /[\^_{}]|\\[a-zA-Z]+/.test(inner)) return true;
}
return false;
}
document.addEventListener('DOMContentLoaded', () => {
// multiple possible containers for math rendering; loop over options
const root = document.querySelector('.article-main'); // the container you render $html into
// Identify containers that may include math and add the .math class when detected
const root = document.querySelector('.article-main'); // main article container
const summaries = document.querySelectorAll('.lede'); // article summaries
if (summaries) {
if (root && hasRealMath(root.textContent || '')) {
root.classList.add('math');
}
if (summaries && summaries.length) {
summaries.forEach((summary) => {
if (summary && hasRealMath(summary.textContent || '')) {
summary.classList.add('math');
}
});
}
// Render KaTeX inside elements marked with .math
const mathRoot = document.querySelector('.article-main.math');
const mathSummaries = document.querySelectorAll('.lede.math');
if (mathSummaries && mathSummaries.length) {
mathSummaries.forEach((summary) => {
renderMathInElement(summary, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$$', right: '$$', display: false },
{ left: '$', right: '$', display: false },
],
throwOnError: false, // don’t explode on unknown commands
@ -71,13 +142,12 @@ document.addEventListener('DOMContentLoaded', () => { @@ -71,13 +142,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (root) {
renderMathInElement(root, {
if (mathRoot) {
renderMathInElement(mathRoot, {
// Delimiters: inline $…$, display $$…$$ and the LaTeX \(…\)/\[…\] forms
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false, // don’t explode on unknown commands

16
assets/styles/03-components/button.css

@ -6,25 +6,21 @@ @@ -6,25 +6,21 @@
button, .btn, a.btn {
background: var(--color-primary);
color: var(--color-text-contrast);
border: 2px solid var(--color-primary);
border: 1px solid var(--color-primary);
padding: 0.75em 1.5em;
font-family: var(--font-family), sans-serif;
font-size: 1rem;
font-weight: 600;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
cursor: pointer;
}
button:hover, .btn:hover, a.btn:hover {
background: var(--color-accent);
color: var(--color-text);
border: 2px solid var(--color-accent);
border: 1px solid var(--color-accent);
}
button:active, .btn:active, a.btn:active {
background: var(--color-primary);
border: 2px solid var(--color-primary);
border: 1px solid var(--color-primary);
}
a.btn, a.btn:hover, a.btn:active {
@ -41,7 +37,7 @@ a.btn, a.btn:hover, a.btn:active { @@ -41,7 +37,7 @@ a.btn, a.btn:hover, a.btn:active {
}
.btn.btn-accent:hover {
border: 2px solid var(--color-secondary);
border: 1px solid var(--color-secondary);
color: var(--color-secondary);
text-decoration: none;
}
@ -49,13 +45,13 @@ a.btn, a.btn:hover, a.btn:active { @@ -49,13 +45,13 @@ a.btn, a.btn:hover, a.btn:active {
.btn.btn-secondary {
color: var(--color-text);
background-color: transparent;
border: 2px solid var(--color-secondary);
border: 1px solid var(--color-secondary);
text-decoration: none;
}
.btn.btn-secondary:hover {
background-color: var(--color-secondary);
color: var(--color-text-contrast);
border: 2px solid var(--color-secondary);
border: 1px solid var(--color-secondary);
text-decoration: none;
}

8
assets/styles/03-components/dropdown.css

@ -48,6 +48,7 @@ @@ -48,6 +48,7 @@
display: block;
}
button.dropdown-item,
.dropdown-item {
display: block;
width: 100%;
@ -64,6 +65,8 @@ @@ -64,6 +65,8 @@
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out;
}
button.dropdown-item:hover,
button.dropdown-item:focus,
.dropdown-item:hover,
.dropdown-item:focus {
color: var(--color-text);
@ -71,14 +74,15 @@ @@ -71,14 +74,15 @@
text-decoration: none;
}
button.dropdown-item:active,
.dropdown-item:active {
color: var(--color-text-contrast);
background-color: var(--color-primary);
text-decoration: none;
}
.dropdown-item.disabled,
.dropdown-item:disabled {
button.dropdown-item.disabled,
button.dropdown-item:disabled {
color: var(--color-text-mid);
pointer-events: none;
background-color: transparent;

125
assets/styles/04-pages/discover.css

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
/* Discover Page Styles */
/* Discover Search Form */
.discover-search-form {
max-width: 100%;
}
.discover-search-form .search {
display: flex;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.discover-search-form input[type="search"] {
flex: 1;
padding: 0.75rem 1rem;
transition: border-color 0.2s;
}
.discover-search-form input[type="search"]:focus {
outline: none;
border-color: var(--primary-color, #007bff);
}
.discover-search-form button[type="submit"] {
padding: 0.75rem 1.5rem;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.5rem;
}
.discover-search-form button[type="submit"] .icon {
width: 1.25rem;
height: 1.25rem;
}
.discover-section .section-heading {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.25rem;
color: var(--text-primary, #1a1a1a);
}
.discover-section .section-subheading {
font-size: 0.95rem;
color: var(--text-secondary, #666);
margin-bottom: 1.5rem;
}
.discover-section .section-header {
border-bottom: 2px solid var(--border-color, #e0e0e0);
padding-bottom: 1rem;
margin-bottom: 2rem;
}
/* Discover Sidebar */
.discover-sidebar {
position: sticky;
top: 2rem;
}
.discover-sidebar .sidebar-section {
background: var(--card-bg, #f8f9fa);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
}
.discover-sidebar h3 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-primary, #1a1a1a);
}
.discover-sidebar ul li {
margin-bottom: 0.75rem;
}
.discover-sidebar ul li a {
color: var(--text-primary, #1a1a1a);
text-decoration: none;
transition: color 0.2s;
}
.discover-sidebar ul li a:hover {
color: var(--primary-color, #007bff);
}
/* Highlights Preview Grid */
#highlights-preview .highlights-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
#highlights-preview .highlights-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.discover-section .section-header {
flex-direction: column;
align-items: flex-start !important;
}
.discover-section .section-header .btn {
margin-top: 1rem;
}
.discover-search-form .search {
flex-direction: column;
}
.discover-search-form button[type="submit"] {
width: 100%;
justify-content: center;
}
}

5
config/packages/cache.yaml

@ -7,8 +7,9 @@ framework: @@ -7,8 +7,9 @@ framework:
# The data in this cache should persist between deploys.
# Other options include:
# Redis
app: cache.adapter.redis
# Use filesystem for the default app cache so the site keeps working if Redis is down
app: cache.adapter.filesystem
# Keep Redis available for specific pools
default_redis_provider: Redis
# Namespaced pools use the above "app" backend by default

2
config/packages/framework.yaml

@ -5,7 +5,7 @@ framework: @@ -5,7 +5,7 @@ framework:
# Note that the session will be started ONLY if you read or write from it.
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler
handler_id: App\Session\GracefulSessionHandler
cookie_secure: auto
cookie_samesite: lax
cookie_lifetime: 2678400 # integer, lifetime in seconds, 0 means 'valid for the length of the browser session'

13
config/services.yaml

@ -53,19 +53,30 @@ services: @@ -53,19 +53,30 @@ services:
Redis:
# you can also use \RedisArray, \RedisCluster, \Relay\Relay or \Predis\Client classes
class: Redis
lazy: true
calls:
- connect:
- '%env(REDIS_HOST)%'
- auth:
- '%env(REDIS_PASSWORD)%'
# Native file session handler for fallback
Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler: ~
# Graceful session handler that falls back to files on Redis outage
App\Session\GracefulSessionHandler:
arguments:
- '@Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler'
- '@Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler'
- '@logger'
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' }
- { name: kernel.event_listener, event: 'FOS\\ElasticaBundle\\Event\\PostIndexPopulateEvent', method: 'postIndexPopulate' }
App\Command\IndexArticlesCommand:
arguments:

2
src/Controller/ArticleController.php

@ -101,7 +101,7 @@ class ArticleController extends AbstractController @@ -101,7 +101,7 @@ class ArticleController extends AbstractController
]);
}
#[Route('/p/{npub}/article/{slug}', name: 'author-article-slug', requirements: ['slug' => '.+'])]
#[Route('/p/{npub}/d/{slug}', name: 'author-article-slug', requirements: ['slug' => '.+'])]
public function authorArticle(
$npub,
$slug,

118
src/Controller/DefaultController.php

@ -7,8 +7,10 @@ namespace App\Controller; @@ -7,8 +7,10 @@ namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event;
use App\Enum\KindsEnum;
use App\Service\NostrClient;
use App\Service\RedisCacheService;
use App\Util\CommonMark\Converter;
use App\Util\ForumTopics;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface;
use Elastica\Collapse;
@ -50,61 +52,53 @@ class DefaultController extends AbstractController @@ -50,61 +52,53 @@ class DefaultController extends AbstractController
}
/**
* @throws Exception
* @throws Exception|InvalidArgumentException
*/
#[Route('/latest-articles', name: 'latest_articles')]
public function latestArticles(FinderInterface $finder,
#[Route('/discover', name: 'discover')]
public function discover(
FinderInterface $finder,
RedisCacheService $redisCacheService,
CacheItemPoolInterface $articlesCache): Response
CacheItemPoolInterface $articlesCache
): Response
{
set_time_limit(300); // 5 minutes
set_time_limit(300);
ini_set('max_execution_time', '300');
$env = $this->getParameter('kernel.environment');
$cacheKey = 'latest_articles_list_' . $env ; // Use env to differentiate cache between environments
// Reuse previous latest list cache key to show same set as old 'latest'
$cacheKey = 'latest_articles_list_' . $env;
$cacheItem = $articlesCache->getItem($cacheKey);
$key = new Key();
$excludedPubkeys = [
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
$key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying)
$key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed 𝚋𝚘𝚝 (Just annoying)
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW
$key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs, job offers feed
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'),
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'),
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'),
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'),
$key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'),
$key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'),
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'),
$key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'),
];
if (!$cacheItem->isHit()) {
// Query all articles and sort by created_at descending
// Fallback: run query now if command hasn't populated cache yet
$boolQuery = new BoolQuery();
$boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys));
$query = new Query($boolQuery);
$query->setSize(30);
$query->setSize(50);
$query->setSort(['createdAt' => ['order' => 'desc']]);
// Use collapse to deduplicate by slug field
$collapse = new Collapse();
$collapse->setFieldname('slug');
$query->setCollapse($collapse);
// Use collapse to deduplicate by author
$collapse2 = new Collapse();
$collapse2->setFieldname('pubkey');
$query->setCollapse($collapse2);
$collapseSlug = new Collapse();
$collapseSlug->setFieldname('slug');
$query->setCollapse($collapseSlug);
$articles = $finder->find($query);
$cacheItem->set($articles);
$cacheItem->expiresAfter(3600); // Cache for 1 hour
$cacheItem->expiresAfter(3600); // 1 hour to match command cache duration
$articlesCache->save($cacheItem);
}
$articles = $cacheItem->get();
// Collect all unique author pubkeys from articles
$authorPubkeys = [];
foreach ($articles as $article) {
if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
@ -114,8 +108,68 @@ class DefaultController extends AbstractController @@ -114,8 +108,68 @@ class DefaultController extends AbstractController
}
}
$authorPubkeys = array_unique($authorPubkeys);
$authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
// Fetch all author metadata in one batch using pubkeys
// Build main topics key => display name map from ForumTopics constant
$mainTopicsMap = [];
foreach (ForumTopics::TOPICS as $key => $data) {
$name = $data['name'] ?? ucfirst($key);
$mainTopicsMap[$key] = $name;
}
return $this->render('pages/discover.html.twig', [
'articles' => $articles,
'authorsMetadata' => $authorsMetadata,
'mainTopicsMap' => $mainTopicsMap,
]);
}
/**
* @throws Exception
*/
#[Route('/latest-articles', name: 'latest_articles')]
public function latestArticles(
RedisCacheService $redisCacheService,
NostrClient $nostrClient
): Response
{
set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300');
// Direct feed: always fetch fresh from relay, no caching
$key = new Key();
$excludedPubkeys = [
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
$key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying)
$key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed bot
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW
$key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs
];
// Fetch raw latest articles (limit 50) directly from relay
$articles = $nostrClient->getLatestLongFormArticles(50);
// Filter out excluded pubkeys
$articles = array_filter($articles, function($article) use ($excludedPubkeys) {
if (method_exists($article, 'getPubkey')) {
return !in_array($article->getPubkey(), $excludedPubkeys, true);
}
return true;
});
// Collect author pubkeys for metadata
$authorPubkeys = [];
foreach ($articles as $article) {
if (method_exists($article, 'getPubkey')) {
$authorPubkeys[] = $article->getPubkey();
} elseif (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
$authorPubkeys[] = $article->pubkey;
}
}
$authorPubkeys = array_unique($authorPubkeys);
$authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
return $this->render('pages/latest-articles.html.twig', [

61
src/Controller/ForumController.php

@ -93,6 +93,67 @@ class ForumController extends AbstractController @@ -93,6 +93,67 @@ class ForumController extends AbstractController
]);
}
#[Route('/forum/main/{topic}', name: 'forum_main_topic')]
public function mainTopic(
string $topic,
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder,
#[Autowire(service: 'fos_elastica.index.articles')] \Elastica\Index $index,
Request $request
): Response {
$catKey = strtolower(trim($topic));
if (!isset(ForumTopics::TOPICS[$catKey])) {
throw $this->createNotFoundException('Main topic not found');
}
$category = ForumTopics::TOPICS[$catKey];
// Collect all tags from all subcategories under this main topic
$tags = [];
foreach ($category['subcategories'] as $sub) {
foreach ($sub['tags'] as $t) { $tags[] = (string)$t; }
}
$tags = array_values(array_unique(array_map('strtolower', array_map('trim', $tags))));
// Count each tag in this main topic in one shot
$tagCounts = $this->fetchTagCounts($index, $tags);
// Fetch articles for the main topic (OR across all tags), collapse by slug
$bool = new BoolQuery();
if (!empty($tags)) {
$bool->addFilter(new Terms('topics', $tags));
}
$query = new Query($bool);
$query->setSize(20);
$query->setSort(['createdAt' => ['order' => 'desc']]);
$collapse = new Collapse();
$collapse->setFieldname('slug');
$query->setCollapse($collapse);
/** @var Pagerfanta $pager */
$pager = $finder->findPaginated($query);
$pager->setMaxPerPage(20);
$pager->setCurrentPage(max(1, (int)$request->query->get('page', 1)));
$articles = iterator_to_array($pager->getCurrentPageResults());
// Latest threads under this main topic scope
$page = max(1, (int)$request->query->get('page', 1));
$perPage = 20;
$threads = $this->fetchThreads($index, [$tags]);
$threadsPage = array_slice($threads, ($page-1)*$perPage, $perPage);
return $this->render('forum/main_topic.html.twig', [
'categoryKey' => $catKey,
'category' => [ 'name' => $category['name'] ?? ucfirst($catKey) ],
'tags' => $tagCounts,
'threads' => $threadsPage,
'total' => count($threads),
'page' => $page,
'perPage' => $perPage,
'topics' => $this->getHydratedTopics($index),
'articles' => $articles,
'pager' => $pager,
]);
}
#[Route('/forum/topic/{key}', name: 'forum_topic')]
public function topic(
string $key,

2
src/Controller/MediaDiscoveryController.php

@ -21,7 +21,7 @@ class MediaDiscoveryController extends AbstractController @@ -21,7 +21,7 @@ class MediaDiscoveryController extends AbstractController
'travel' => ['travel', 'traveling', 'wanderlust', 'adventure', 'explore', 'city', 'vacation', 'trip'],
];
#[Route('/discover', name: 'media-discovery')]
#[Route('/multimedia', name: 'media-discovery')]
public function discover(CacheInterface $cache): Response
{
// Defaulting to all, might do topics later

6
src/Controller/SearchController.php

@ -6,16 +6,20 @@ namespace App\Controller; @@ -6,16 +6,20 @@ namespace App\Controller;
use App\Util\ForumTopics;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SearchController extends AbstractController
{
#[Route('/search')]
public function index(): Response
public function index(Request $request): Response
{
$query = $request->query->get('q', '');
return $this->render('pages/search.html.twig', [
'topics' => ForumTopics::TOPICS,
'initialQuery' => $query,
]);
}
}

71
src/Service/NostrClient.php

@ -1137,6 +1137,75 @@ class NostrClient @@ -1137,6 +1137,75 @@ class NostrClient
return array_values($uniqueEvents);
}
public function getLatestLongFormArticles(int $limit = 50, ?int $since = null): array
{
// Prefer ONLY the local relay if configured; otherwise use the default relay set
if ($this->nostrDefaultRelay) {
$relaySet = $this->createRelaySet([$this->nostrDefaultRelay]);
$this->logger->info('Fetching latest long-form articles from local relay', [
'relay' => $this->nostrDefaultRelay,
'limit' => $limit,
'since' => $since
]);
} else {
$relaySet = $this->defaultRelaySet;
$this->logger->info('Fetching latest long-form articles from default relay set (no local relay configured)', [
'limit' => $limit,
'since' => $since
]);
}
$filters = [ 'limit' => $limit ];
if ($since !== null && $since > 0) {
$filters['since'] = $since;
}
try {
$request = $this->createNostrRequest(
kinds: [KindsEnum::LONGFORM->value],
filters: $filters,
relaySet: $relaySet
);
$events = $this->processResponse($request->send(), function($event) {
try {
$article = $this->articleFactory->createFromLongFormContentEvent($event);
// Persist newest revision if not saved yet
$this->saveEachArticleToTheDatabase($article);
return $article;
} catch (\Throwable $e) {
$this->logger->error('Failed converting event to Article', [
'error' => $e->getMessage(),
'event_id' => $event->id ?? null
]);
return null;
}
});
} catch (\Throwable $e) {
$this->logger->error('Error fetching latest long-form articles', [ 'error' => $e->getMessage() ]);
return [];
}
// Filter out nulls
$articles = array_filter($events, fn($a) => $a instanceof Article);
// Deduplicate by slug keeping latest createdAt
$bySlug = [];
foreach ($articles as $article) {
$slug = $article->getSlug();
if ($slug === '') { continue; }
if (!isset($bySlug[$slug]) || $article->getCreatedAt() > $bySlug[$slug]->getCreatedAt()) {
$bySlug[$slug] = $article;
}
}
// Sort descending by createdAt
$deduped = array_values($bySlug);
usort($deduped, fn($a, $b) => $b->getCreatedAt() <=> $a->getCreatedAt());
return $deduped;
}
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest
{
$subscription = new Subscription();
@ -1425,7 +1494,7 @@ class NostrClient @@ -1425,7 +1494,7 @@ class NostrClient
$cachedItem = $this->npubCache->getItem($cacheKey);
if (!$cachedItem->isHit() || ($event->created_at ?? 0) > ($cachedItem->get()->created_at ?? 0)) {
$cachedItem->set($event);
$cachedItem->expiresAfter(3600); // 1 hour TTL
$cachedItem->expiresAfter(84000); // 24 hours
$this->npubCache->save($cachedItem);
}
} catch (\Throwable $e) {

52
src/Service/RedisCacheService.php

@ -131,7 +131,7 @@ readonly class RedisCacheService @@ -131,7 +131,7 @@ readonly class RedisCacheService
/**
* Fetch metadata for multiple pubkeys at once using Redis getItems.
* Falls back to getMetadata for cache misses.
* Batch fetches missing pubkeys from Nostr relays (local relay first, then public fallback).
*
* @param string[] $pubkeys Array of hex pubkeys
* @return array<string, \stdClass> Map of pubkey => metadata
@ -154,10 +154,58 @@ readonly class RedisCacheService @@ -154,10 +154,58 @@ readonly class RedisCacheService
$result[$pubkey] = $item->get();
}
}
// Batch fetch missing pubkeys using NostrClient (local relay first, fallback to public)
$missedPubkeys = array_diff($pubkeys, array_keys($result));
if (!empty($missedPubkeys)) {
try {
$batchMetadata = $this->nostrClient->getMetadataForPubkeys(array_values($missedPubkeys), true);
foreach ($missedPubkeys as $pubkey) {
$result[$pubkey] = $this->getMetadata($pubkey);
if (isset($batchMetadata[$pubkey])) {
// Parse and cache the metadata
$parsed = $this->parseUserMetadata($batchMetadata[$pubkey], $pubkey);
$result[$pubkey] = $parsed;
// Store in cache for future use
try {
$cacheKey = $this->getUserCacheKey($pubkey);
$item = $this->npubCache->getItem($cacheKey);
$item->set($parsed);
$item->expiresAfter(3600); // 1 hour
$this->npubCache->save($item);
} catch (\Throwable $e) {
$this->logger->warning('Failed to cache batch-fetched metadata', [
'pubkey' => $pubkey,
'error' => $e->getMessage()
]);
}
} else {
// Fallback to default metadata if not found
$npub = NostrKeyUtil::hexToNpub($pubkey);
$defaultName = '@' . substr($npub, 5, 4) . '…' . substr($npub, -4);
$content = new \stdClass();
$content->name = $defaultName;
$result[$pubkey] = $content;
}
}
} catch (\Throwable $e) {
$this->logger->error('Batch metadata fetch failed, using defaults', [
'error' => $e->getMessage(),
'pubkeys_count' => count($missedPubkeys)
]);
// Fallback: create default metadata for all missed pubkeys
foreach ($missedPubkeys as $pubkey) {
$npub = NostrKeyUtil::hexToNpub($pubkey);
$defaultName = '@' . substr($npub, 5, 4) . '…' . substr($npub, -4);
$content = new \stdClass();
$content->name = $defaultName;
$result[$pubkey] = $content;
}
}
}
return $result;
}

119
src/Session/GracefulSessionHandler.php

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
<?php
namespace App\Session;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler;
use Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler;
/**
* A session handler that tries Redis first, but gracefully falls back to files
* if Redis is unavailable at runtime.
*/
class GracefulSessionHandler extends AbstractSessionHandler
{
private RedisSessionHandler $redisHandler;
private NativeFileSessionHandler $fileHandler;
private LoggerInterface $logger;
private bool $useRedis = true;
private bool $handlerOpened = false;
public function __construct(RedisSessionHandler $redisHandler, NativeFileSessionHandler $fileHandler, LoggerInterface $logger)
{
$this->redisHandler = $redisHandler;
$this->fileHandler = $fileHandler;
$this->logger = $logger;
}
protected function doRead(string $sessionId): string
{
if ($this->useRedis) {
try {
$this->handlerOpened = true;
return $this->redisHandler->read($sessionId);
} catch (\Throwable $e) {
$this->useRedis = false;
$this->logger->warning('Redis session read failed; falling back to files', ['e' => $e->getMessage()]);
}
}
$this->handlerOpened = true;
return $this->fileHandler->read($sessionId);
}
public function updateTimestamp(string $sessionId, string $data): bool
{
if ($this->useRedis) {
try {
return $this->redisHandler->updateTimestamp($sessionId, $data);
} catch (\Throwable $e) {
$this->useRedis = false;
$this->logger->warning('Redis session touch failed; falling back to files', ['e' => $e->getMessage()]);
}
}
// NativeFileSessionHandler doesn't support updateTimestamp, just return true
return true;
}
protected function doWrite(string $sessionId, string $data): bool
{
if ($this->useRedis) {
try {
return $this->redisHandler->write($sessionId, $data);
} catch (\Throwable $e) {
$this->useRedis = false;
$this->logger->warning('Redis session write failed; falling back to files', ['e' => $e->getMessage()]);
}
}
return $this->fileHandler->write($sessionId, $data);
}
protected function doDestroy(string $sessionId): bool
{
if ($this->useRedis) {
try {
return $this->redisHandler->destroy($sessionId);
} catch (\Throwable $e) {
$this->useRedis = false;
$this->logger->warning('Redis session destroy failed; falling back to files', ['e' => $e->getMessage()]);
}
}
return $this->fileHandler->destroy($sessionId);
}
public function close(): bool
{
if (!$this->handlerOpened) {
return true;
}
try {
if ($this->useRedis) {
return $this->redisHandler->close();
} else {
return $this->fileHandler->close();
}
} catch (\Throwable $e) {
$this->logger->warning('Session close error', ['e' => $e->getMessage()]);
return false;
} finally {
$this->handlerOpened = false;
}
}
public function gc(int $max_lifetime): int|false
{
if ($this->useRedis) {
try {
return $this->redisHandler->gc($max_lifetime);
} catch (\Throwable $e) {
$this->useRedis = false;
$this->logger->warning('Redis session gc failed; falling back to files', ['e' => $e->getMessage()]);
}
}
return $this->fileHandler->gc($max_lifetime);
}
}

68
src/Twig/Components/SearchComponent.php

@ -65,12 +65,16 @@ final class SearchComponent @@ -65,12 +65,16 @@ final class SearchComponent
{
}
public function mount($currentRoute = 'search'): void
public function mount($query = '', $currentRoute = 'search'): void
{
$this->currentRoute = $currentRoute;
$this->query = $query;
$token = $this->tokenStorage->getToken();
$this->npub = $token?->getUserIdentifier();
$this->logger->info('SearchComponent mount called with query: "' . $this->query . '"');
// Credits are only relevant for authenticated users, but search works for everyone
if ($this->npub !== null) {
try {
$this->credits = $this->creditsManager->getBalance($this->npub);
@ -81,8 +85,29 @@ final class SearchComponent @@ -81,8 +85,29 @@ final class SearchComponent
}
}
// Restore search results from session if available and no query provided
if (empty($this->query) && $this->currentRoute == 'search') {
// If a query is provided (from URL or prop), perform the search automatically
if (!empty($this->query)) {
$this->logger->info('Query detected in mount, triggering search for: ' . $this->query);
// Clear cache if this is a different query than what's cached
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY)) {
$cachedQuery = $session->get(self::SESSION_QUERY_KEY);
if ($cachedQuery !== $this->query) {
$this->clearSearchCache();
$this->logger->info('Cleared cache for different query. Old: ' . $cachedQuery . ', New: ' . $this->query);
}
}
try {
$this->search();
} catch (InvalidArgumentException $e) {
$this->logger->error('Search error on mount: ' . $e->getMessage());
}
return;
}
// Otherwise, restore search results from session if available
if ($this->currentRoute == 'search') {
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY)) {
$this->query = $session->get(self::SESSION_QUERY_KEY);
@ -102,8 +127,9 @@ final class SearchComponent @@ -102,8 +127,9 @@ final class SearchComponent
{
$token = $this->tokenStorage->getToken();
$this->npub = $token?->getUserIdentifier();
$isAuthenticated = $this->npub !== null;
$this->logger->info("Query: {$this->query}, npub: {$this->npub}");
$this->logger->info("Query: {$this->query}, npub: " . ($this->npub ?? 'anonymous'));
if (empty($this->query)) {
$this->results = [];
@ -112,13 +138,16 @@ final class SearchComponent @@ -112,13 +138,16 @@ final class SearchComponent
return;
}
// Update credits for authenticated users
if ($isAuthenticated) {
try {
$this->credits = $this->creditsManager->getBalance($this->npub);
} catch (InvalidArgumentException $e) {
$this->credits = $this->creditsManager->resetBalance($this->npub);
}
}
// Check if the same query exists in session
// Check if the same query exists in session (works for both auth and anon)
$session = $this->requestStack->getSession();
if ($session->has(self::SESSION_QUERY_KEY) &&
$session->get(self::SESSION_QUERY_KEY) === $this->query) {
@ -129,19 +158,21 @@ final class SearchComponent @@ -129,19 +158,21 @@ final class SearchComponent
return;
}
if (!$this->creditsManager->canAfford($this->npub, 1)) {
$this->results = [];
$this->authors = [];
return;
}
try {
$this->results = [];
// Only spend credits if user is authenticated
if ($isAuthenticated && $this->creditsManager->canAfford($this->npub, 1)) {
$this->creditsManager->spendCredits($this->npub, 1, 'search');
$this->credits--;
}
// Set result limits: 5 for anonymous, 12 for authenticated users
$maxResults = $isAuthenticated ? 12 : 5;
$this->logger->info('Search limit: ' . $maxResults . ' results for ' . ($isAuthenticated ? 'authenticated' : 'anonymous') . ' user');
// Perform optimized single search query
$this->results = $this->performOptimizedSearch($this->query);
// Perform optimized single search query with appropriate limit
$this->results = $this->performOptimizedSearch($this->query, $maxResults);
$pubkeys = array_unique(array_map(fn($art) => $art->getPubkey(), $this->results));
$this->authors = $this->redisCacheService->getMultipleMetadata($pubkeys);
@ -179,8 +210,10 @@ final class SearchComponent @@ -179,8 +210,10 @@ final class SearchComponent
/**
* Perform optimized single search query
* @param string $query The search query
* @param int|null $maxResults Maximum number of results (null for default)
*/
private function performOptimizedSearch(string $query): array
private function performOptimizedSearch(string $query, ?int $maxResults = null): array
{
$mainQuery = new Query();
$boolQuery = new BoolQuery();
@ -219,10 +252,11 @@ final class SearchComponent @@ -219,10 +252,11 @@ final class SearchComponent
'createdAt' => ['order' => 'desc']
]);
// Pagination
$offset = ($this->page - 1) * $this->resultsPerPage;
// Pagination - use maxResults if provided, otherwise use default resultsPerPage
$effectiveResultsPerPage = $maxResults ?? $this->resultsPerPage;
$offset = ($this->page - 1) * $effectiveResultsPerPage;
$mainQuery->setFrom($offset);
$mainQuery->setSize($this->resultsPerPage);
$mainQuery->setSize($effectiveResultsPerPage);
// Execute the search
$results = $this->finder->find($mainQuery);

2
sync-strfry.sh

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
# Sync articles, comments, media, and profiles from upstream relays
# Event kinds: 30023 (articles), 30024 (drafts), 1111 (comments), 20 (pictures), 21 (videos), 22 (short videos), 0 (profiles), 9802 (highlights)
KINDS='{"kinds":[30023,30024,1111,20,21,22,0,9802]}'
KINDS='{"kinds":[30023,30024,1111,20,21,22,0,9802],"limit":5000}'
echo "Starting relay sync at $(date)"

4
templates/components/Atoms/ForumAside.html.twig

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
<div class="d-flex gap-3 center mt-3 mb-3 ln-section--reader">
<h2 class="mb-4">Forum</h2>
<h2 class="mb-4">
<a href="{{ path('forum') }}">Topics</a>
</h2>
</div>
{% for catKey, category in topics %}
<details class="mb-2">

28
templates/components/SearchComponent.html.twig

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
<div {{ attributes }}>
{% if interactive %}
<form data-live-action-param="search"
<form
class="mb-5"
data-live-action-param="search"
data-action="live#action:prevent">
<label class="search">
<input type="search"
@ -10,27 +12,20 @@ @@ -10,27 +12,20 @@
data-controller="search-broadcast"
data-action="input->search-broadcast#onInput"
/>
<button type="submit"
{% if not is_granted('IS_AUTHENTICATED_FULLY') %}disabled{% endif %}
><twig:ux:icon name="iconoir:search" class="icon" /></button>
<button type="submit"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>
{% if is_granted('IS_AUTHENTICATED_FULLY') %}
<div class="search-credits">
{% set count = credits > 0 ? credits : 0 %}
<small class="help-text">
<em>{{ 'credit.balance'|trans({'%count%': credits, 'count': credits}) }}</em>
<em>{{ 'credit.balance'|trans({'%count%': count, 'count': count}) }}</em>
</small>
</div>
{% endif %}
</form>
{% if not is_granted('IS_AUTHENTICATED_FULLY') %}
<div class="notice info mb-5">
<p>Log in to search articles.</p>
</div>
{% endif %}
{% if is_granted('IS_AUTHENTICATED_FULLY') and credits == 0 %}
{% if is_granted('IS_AUTHENTICATED_FULLY') and not credits > 0 %}
<div class="mb-5">
<twig:GetCreditsComponent />
</div>
@ -80,6 +75,15 @@ @@ -80,6 +75,15 @@
</div>
{% else %}
<twig:Organisms:CardList :list="this.results" class="article-list" />
{# Show message to anonymous users about limited results #}
{% if not is_granted('IS_AUTHENTICATED_FULLY') and this.results is not empty %}
<div class="notice info mt-4">
<p><strong>Showing limited results (5 articles)</strong></p>
<p>Sign in to see more results and unlock full search capabilities!</p>
<a href="{{ path('app_login') }}" class="btn btn-primary btn-sm mt-2">Sign In</a>
</div>
{% endif %}
{% endif %}
{% elseif this.query is not empty %}
<p><small>{{ 'text.noResults'|trans }}</small></p>

4
templates/forum/index.html.twig

@ -31,7 +31,9 @@ @@ -31,7 +31,9 @@
{% for catKey, category in topics %}
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-5 mb-5">
<h2>{{ category.name }}</h2>
<h2>
<a href="{{ path('forum_main_topic', { topic: catKey }) }}">{{ category.name }}</a>
</h2>
</div>
</section>
<div class="subcategories-grid">

32
templates/forum/main_topic.html.twig

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
{% extends 'layout.html.twig' %}
{% block body %}
<twig:Atoms:PageHeading heading="{{ category.name }}"/>
<div class="subcategories-grid">
{% set cat = topics[categoryKey] ?? null %}
{% if cat %}
{% for subKey, sub in cat.subcategories %}
<div class="sub-card">
<h3>
<a href="{{ path('forum_topic', {'key': categoryKey ~ '-' ~ subKey}) }}">{{ sub.name }}</a>
</h3>
<div class="d-flex flex-row">
<div class="tags m-0">
{% for tag in sub.tags %}
<a class="tag" href="{{ path('forum_tag', {'tag': tag}) }}">{{ tag }}</a>
{% endfor %}
</div>
<div class="count">{{ sub.count|default(0) }}</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">No subcategories available for this topic.</div>
{% endif %}
</div>
{% endblock %}
{% block aside %}
<twig:Atoms:ForumAside />
{% endblock %}

8
templates/layout.html.twig

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<a href="{{ path('newsstand') }}">Newsstand</a>
</li>
<li>
<a href="{{ path('latest_articles') }}">Latest</a>
<a href="{{ path('discover') }}">Explore</a>
</li>
<li>
<a href="{{ path('forum') }}">Topics</a>
@ -23,12 +23,6 @@ @@ -23,12 +23,6 @@
<li>
<a href="{{ path('highlights') }}">Highlights</a>
</li>
<li>
<a href="{{ path('lists') }}">Lists</a>
</li>
<li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>
</li>
<li>
<a href="{{ path('editor-create') }}">Write</a>
</li>

57
templates/pages/discover.html.twig

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
{% extends 'layout.html.twig' %}
{% block body %}
{# Search Section #}
<section class="w-container">
<div class="discover-section">
<form action="{{ path('app_search_index') }}" method="get" class="discover-search-form">
<label class="search">
<input type="search"
name="q"
placeholder="Search for articles..."
class="form-control"
required
/>
<button type="submit" class="btn btn-primary">
<twig:ux:icon name="iconoir:search" class="icon" /> Search
</button>
</label>
</form>
</div>
</section>
{# Main Topics (visible on small screens when sidebar is hidden) #}
<section class="w-container d-md-none mb-3">
<div class="discover-section topics-strip">
{% if mainTopicsMap is defined and mainTopicsMap is not empty %}
<div class="topics-buttons d-flex flex-row flex-wrap gap-2 justify-content-center">
<a href="{{ path('forum') }}"
class="btn btn-outline-primary btn-sm"
>All</a>
{% for key, name in mainTopicsMap %}
<a href="{{ path('forum_main_topic', { topic: key }) }}"
class="btn btn-outline-primary btn-sm">
{{ name }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</section>
<section class="w-container">
<div class="discover-section">
{% if articles is empty %}
<div class="alert alert-info">
No articles found.
</div>
{% else %}
<twig:Organisms:CardList :list="articles" :authorsMetadata="authorsMetadata" class="article-list" />
{% endif %}
</div>
</section>
{% endblock %}
{% block aside %}
<twig:Atoms:ForumAside />
{% endblock %}

11
templates/pages/search.html.twig

@ -4,14 +4,11 @@ @@ -4,14 +4,11 @@
{% endblock %}
{% block body %}
<section class="d-flex gap-3 center ln-section--newsstand mb-5">
<div class="container mb-5 mt-5">
<h1>Discover</h1>
<p class="eyebrow">hidden gems</p>
</div>
</section>
<twig:Atoms:PageHeading
heading="Discover"
tagline="hidden gems" />
<section>
<twig:SearchComponent />
<twig:SearchComponent :query="initialQuery" />
</section>
{% endblock %}

Loading…
Cancel
Save