ADD UPDATE SCRIPT v0.23
This commit is contained in:
@@ -88,6 +88,33 @@ publiques se referment automatiquement après.
|
|||||||
Relancez simplement `bash scriptsite.sh` : il récupère les derniers changements et reconstruit
|
Relancez simplement `bash scriptsite.sh` : il récupère les derniers changements et reconstruit
|
||||||
la stack sans toucher à vos données.
|
la stack sans toucher à vos données.
|
||||||
|
|
||||||
|
Avant chaque redéploiement (s'il existe déjà une instance en cours), le script sauvegarde
|
||||||
|
automatiquement la base de données dans `backups/bloomfeed-<date>.sql.gz` (les 10 dernières
|
||||||
|
sauvegardes sont conservées). Si la sauvegarde échoue, la mise à jour est annulée pour ne
|
||||||
|
jamais risquer de perdre des données. Pour restaurer une sauvegarde :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gunzip -c backups/bloomfeed-20260706-120000.sql.gz \
|
||||||
|
| sudo docker compose exec -T db sh -c 'exec mariadb -u root -p"$MARIADB_ROOT_PASSWORD"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mise à jour automatique
|
||||||
|
|
||||||
|
`auto-update.sh` vérifie périodiquement si le dépôt a de nouveaux commits sur `origin/main` et
|
||||||
|
ne redéploie (via `scriptsite.sh`) que si c'est le cas — une instance à jour ne reconstruit jamais
|
||||||
|
pour rien. Le mécanisme tourne sur l'hôte via une tâche cron dédiée (`/etc/cron.d/bloomfeed-auto-update`),
|
||||||
|
jamais depuis le conteneur applicatif (qui n'a pas accès au démon Docker, par sécurité).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash auto-update.sh --install # active la vérification toutes les 30 min (défaut)
|
||||||
|
bash auto-update.sh --install 60 # ou toutes les 60 min
|
||||||
|
bash auto-update.sh --status # état actuel + derniers logs
|
||||||
|
bash auto-update.sh --force # force un redéploiement immédiat
|
||||||
|
bash auto-update.sh --uninstall # désactive la mise à jour automatique
|
||||||
|
```
|
||||||
|
|
||||||
|
Les logs sont écrits dans `storage/logs/auto-update.log` à l'intérieur du dépôt.
|
||||||
|
|
||||||
### Réinitialisation
|
### Réinitialisation
|
||||||
|
|
||||||
Pour repartir d'une instance vierge (efface comptes, flux, articles et notifieurs) sans toucher
|
Pour repartir d'une instance vierge (efface comptes, flux, articles et notifieurs) sans toucher
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class PageController extends Controller
|
class PageController extends Controller
|
||||||
@@ -23,4 +24,52 @@ class PageController extends Controller
|
|||||||
{
|
{
|
||||||
return view('self-hosting');
|
return view('self-hosting');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow/Disallow use paths, not absolute URLs, per the robots.txt spec.
|
||||||
|
* Only the Sitemap directive takes an absolute URL, generated from this
|
||||||
|
* instance's own domain so every self-hosted BloomFeed gets it for free.
|
||||||
|
*/
|
||||||
|
public function robots(): Response
|
||||||
|
{
|
||||||
|
$lines = [
|
||||||
|
'User-agent: *',
|
||||||
|
'Allow: /',
|
||||||
|
'Allow: /self-hosting',
|
||||||
|
'Disallow: /login',
|
||||||
|
'Disallow: /register',
|
||||||
|
'Disallow: /dashboard',
|
||||||
|
'Disallow: /feeds',
|
||||||
|
'Disallow: /catalog',
|
||||||
|
'Disallow: /notifiers',
|
||||||
|
'Disallow: /settings',
|
||||||
|
'Disallow: /articles',
|
||||||
|
'',
|
||||||
|
'Sitemap: '.route('sitemap'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return response(implode("\n", $lines), 200)->header('Content-Type', 'text/plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sitemap(): Response
|
||||||
|
{
|
||||||
|
$urls = [
|
||||||
|
['loc' => route('home'), 'priority' => '1.0'],
|
||||||
|
['loc' => route('self-hosting'), 'priority' => '0.8'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
|
||||||
|
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
|
||||||
|
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
$xml .= " <url>\n";
|
||||||
|
$xml .= ' <loc>'.e($url['loc'])."</loc>\n";
|
||||||
|
$xml .= ' <priority>'.$url['priority']."</priority>\n";
|
||||||
|
$xml .= " </url>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= '</urlset>';
|
||||||
|
|
||||||
|
return response($xml, 200)->header('Content-Type', 'application/xml');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Checks whether the BloomFeed git repository has new commits on origin/main
|
||||||
|
# and, if so, redeploys via scriptsite.sh. Designed to be run periodically by
|
||||||
|
# a cron job installed with --install (see below). Only redeploys when there
|
||||||
|
# is actually something new, so an idle instance never rebuilds for nothing.
|
||||||
|
|
||||||
|
APP_DIR="/opt/bloomfeed"
|
||||||
|
CRON_FILE="/etc/cron.d/bloomfeed-auto-update"
|
||||||
|
LOG_DIR="${APP_DIR}/storage/logs"
|
||||||
|
LOG_FILE="${LOG_DIR}/auto-update.log"
|
||||||
|
INTERVAL_MINUTES=30
|
||||||
|
FORCE=false
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "$(date -Iseconds) $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--install)
|
||||||
|
INTERVAL_MINUTES="${2:-30}"
|
||||||
|
if ! [[ "${INTERVAL_MINUTES}" =~ ^[0-9]+$ ]] || [[ "${INTERVAL_MINUTES}" -lt 1 || "${INTERVAL_MINUTES}" -gt 1440 ]]; then
|
||||||
|
echo "ERROR: interval must be a number of minutes between 1 and 1440." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo mkdir -p "${LOG_DIR}"
|
||||||
|
echo "*/${INTERVAL_MINUTES} * * * * root ${APP_DIR}/auto-update.sh >> ${LOG_FILE} 2>&1" | sudo tee "${CRON_FILE}" >/dev/null
|
||||||
|
sudo chmod 644 "${CRON_FILE}"
|
||||||
|
echo "==> Auto-update installed: checking for new commits every ${INTERVAL_MINUTES} minute(s)."
|
||||||
|
echo " Cron file: ${CRON_FILE}"
|
||||||
|
echo " Logs: ${LOG_FILE}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--uninstall)
|
||||||
|
sudo rm -f "${CRON_FILE}"
|
||||||
|
echo "==> Auto-update disabled (cron file removed)."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--status)
|
||||||
|
if [[ -f "${CRON_FILE}" ]]; then
|
||||||
|
echo "==> Auto-update is ENABLED:"
|
||||||
|
cat "${CRON_FILE}"
|
||||||
|
else
|
||||||
|
echo "==> Auto-update is DISABLED (no ${CRON_FILE})."
|
||||||
|
fi
|
||||||
|
if [[ -f "${LOG_FILE}" ]]; then
|
||||||
|
echo "==> Last log entries:"
|
||||||
|
tail -n 10 "${LOG_FILE}"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--force)
|
||||||
|
FORCE=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: bash auto-update.sh [--install [minutes]|--uninstall|--status|--force]"
|
||||||
|
echo " (no args) Check for new commits and redeploy if found — meant to be run by cron."
|
||||||
|
echo " --install [N] Install a cron job checking every N minutes (default: 30)."
|
||||||
|
echo " --uninstall Remove the cron job."
|
||||||
|
echo " --status Show whether auto-update is enabled and recent log entries."
|
||||||
|
echo " --force Redeploy immediately, even without a new commit."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
"") ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: ${1} (use --help)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ! -d "${APP_DIR}/.git" ]]; then
|
||||||
|
echo "ERROR: ${APP_DIR} is not a git repository — run scriptsite.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${LOG_DIR}"
|
||||||
|
cd "${APP_DIR}"
|
||||||
|
|
||||||
|
git fetch origin main --quiet
|
||||||
|
|
||||||
|
LOCAL_SHA="$(git rev-parse HEAD)"
|
||||||
|
REMOTE_SHA="$(git rev-parse origin/main)"
|
||||||
|
|
||||||
|
if [[ "${FORCE}" != true && "${LOCAL_SHA}" == "${REMOTE_SHA}" ]]; then
|
||||||
|
log "No update available (up to date at ${LOCAL_SHA:0:7})."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${FORCE}" == true ]]; then
|
||||||
|
log "Forced redeploy requested (currently at ${LOCAL_SHA:0:7})."
|
||||||
|
else
|
||||||
|
log "Update found: ${LOCAL_SHA:0:7} -> ${REMOTE_SHA:0:7}. Redeploying..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if bash "${APP_DIR}/scriptsite.sh"; then
|
||||||
|
log "Auto-update finished successfully (now at $(git rev-parse HEAD | cut -c1-7))."
|
||||||
|
else
|
||||||
|
log "Auto-update FAILED — the instance may be left in a partially deployed state, check the output above."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Forces an immediate redeploy of BloomFeed, regardless of whether a new
|
||||||
|
# commit is actually available. Thin wrapper around auto-update.sh --force
|
||||||
|
# so the intent is explicit and easy to remember/document.
|
||||||
|
|
||||||
|
APP_DIR="/opt/bloomfeed"
|
||||||
|
|
||||||
|
if [[ ! -f "${APP_DIR}/auto-update.sh" ]]; then
|
||||||
|
echo "ERROR: ${APP_DIR}/auto-update.sh not found — run scriptsite.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Forcing an immediate BloomFeed update/redeploy..."
|
||||||
|
exec bash "${APP_DIR}/auto-update.sh" --force
|
||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'settings' => 'Einstellungen',
|
'settings' => 'Einstellungen',
|
||||||
'logout' => 'Abmelden',
|
'logout' => 'Abmelden',
|
||||||
'change_theme' => 'Design wechseln',
|
'change_theme' => 'Design wechseln',
|
||||||
|
'nav_menu' => 'Menü',
|
||||||
'language' => 'Sprache',
|
'language' => 'Sprache',
|
||||||
'self_hosting' => 'Self-Hosting',
|
'self_hosting' => 'Self-Hosting',
|
||||||
'login' => 'Anmelden',
|
'login' => 'Anmelden',
|
||||||
@@ -140,6 +141,7 @@ return [
|
|||||||
'config_catalog' => 'Der integrierte Feed-Katalog lässt sich durch Bearbeiten von resources/data/bloomflux.json und erneutes Deployment anpassen.',
|
'config_catalog' => 'Der integrierte Feed-Katalog lässt sich durch Bearbeiten von resources/data/bloomflux.json und erneutes Deployment anpassen.',
|
||||||
'update_title' => 'Aktualisieren',
|
'update_title' => 'Aktualisieren',
|
||||||
'update_text' => 'Führe einfach das Deployment-Skript erneut aus: Es holt die neuesten Änderungen und baut den Stack neu, ohne deine Daten anzutasten.',
|
'update_text' => 'Führe einfach das Deployment-Skript erneut aus: Es holt die neuesten Änderungen und baut den Stack neu, ohne deine Daten anzutasten.',
|
||||||
|
'backup_note' => 'Vor jedem Redeploy wird die Datenbank automatisch nach backups/ gesichert (die letzten 10 Sicherungen werden aufbewahrt). Schlägt die Sicherung fehl, wird das Update abgebrochen, damit deine Daten niemals gefährdet werden.',
|
||||||
'uninstall_title' => 'Deinstallieren',
|
'uninstall_title' => 'Deinstallieren',
|
||||||
'uninstall_text' => 'Das Skript uninstall.sh stoppt und entfernt den Docker-Stack sauber. Es fragt vor dem Löschen deiner Daten explizit nach und entfernt den Quellcode nie ohne separate Bestätigung.',
|
'uninstall_text' => 'Das Skript uninstall.sh stoppt und entfernt den Docker-Stack sauber. Es fragt vor dem Löschen deiner Daten explizit nach und entfernt den Quellcode nie ohne separate Bestätigung.',
|
||||||
'source_title' => 'Quellcode',
|
'source_title' => 'Quellcode',
|
||||||
@@ -191,4 +193,15 @@ return [
|
|||||||
|
|
||||||
'reset_title' => 'Zurücksetzen',
|
'reset_title' => 'Zurücksetzen',
|
||||||
'reset_text' => 'Das Skript reset.sh setzt die Instanz auf einen leeren Zustand zurück (Konten, Feeds, Artikel, Benachrichtigungen), ohne Quellcode, .env oder Docker-Images anzutasten — nützlich, um mit einer sauberen Instanz neu zu starten.',
|
'reset_text' => 'Das Skript reset.sh setzt die Instanz auf einen leeren Zustand zurück (Konten, Feeds, Artikel, Benachrichtigungen), ohne Quellcode, .env oder Docker-Images anzutasten — nützlich, um mit einer sauberen Instanz neu zu starten.',
|
||||||
|
|
||||||
|
// Automatische Updates
|
||||||
|
'autoupdate_title' => 'Automatische Updates',
|
||||||
|
'autoupdate_text' => 'Das Skript auto-update.sh prüft regelmäßig auf neue Commits im Repository und stellt nur bei Bedarf neu bereit, über einen geplanten Task auf dem Server (niemals aus dem Anwendungscontainer heraus).',
|
||||||
|
'autoupdate_comment_install' => 'prüft alle 30 Minuten',
|
||||||
|
'autoupdate_comment_status' => 'zeigt Status und letzte Logs',
|
||||||
|
'autoupdate_comment_uninstall' => 'deaktiviert automatische Updates',
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
'seo_home_title' => 'BloomFeed — Selbst gehosteter, quelloffener RSS-Aggregator',
|
||||||
|
'seo_selfhost_title' => 'BloomFeed selbst hosten — Docker-Installationsanleitung',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'settings' => 'Settings',
|
'settings' => 'Settings',
|
||||||
'logout' => 'Log out',
|
'logout' => 'Log out',
|
||||||
'change_theme' => 'Switch theme',
|
'change_theme' => 'Switch theme',
|
||||||
|
'nav_menu' => 'Menu',
|
||||||
'language' => 'Language',
|
'language' => 'Language',
|
||||||
'self_hosting' => 'Self-hosting',
|
'self_hosting' => 'Self-hosting',
|
||||||
'login' => 'Log in',
|
'login' => 'Log in',
|
||||||
@@ -140,6 +141,7 @@ return [
|
|||||||
'config_catalog' => 'The built-in feed catalog can be customized by editing resources/data/bloomflux.json and redeploying.',
|
'config_catalog' => 'The built-in feed catalog can be customized by editing resources/data/bloomflux.json and redeploying.',
|
||||||
'update_title' => 'Update',
|
'update_title' => 'Update',
|
||||||
'update_text' => 'Simply re-run the deployment script: it pulls the latest changes and rebuilds the stack without touching your data.',
|
'update_text' => 'Simply re-run the deployment script: it pulls the latest changes and rebuilds the stack without touching your data.',
|
||||||
|
'backup_note' => 'Before every redeploy, the database is automatically backed up to backups/ (the last 10 backups are kept). If the backup fails, the update is aborted so your data is never put at risk.',
|
||||||
'uninstall_title' => 'Uninstall',
|
'uninstall_title' => 'Uninstall',
|
||||||
'uninstall_text' => 'The uninstall.sh script cleanly stops and removes the Docker stack. It asks for explicit confirmation before deleting your data and never removes the source code without a separate confirmation.',
|
'uninstall_text' => 'The uninstall.sh script cleanly stops and removes the Docker stack. It asks for explicit confirmation before deleting your data and never removes the source code without a separate confirmation.',
|
||||||
'source_title' => 'Source code',
|
'source_title' => 'Source code',
|
||||||
@@ -191,4 +193,15 @@ return [
|
|||||||
|
|
||||||
'reset_title' => 'Reset',
|
'reset_title' => 'Reset',
|
||||||
'reset_text' => 'The reset.sh script wipes the instance back to a blank state (accounts, feeds, articles, notifiers) without touching the source code, .env or Docker images — useful to start over on a clean instance.',
|
'reset_text' => 'The reset.sh script wipes the instance back to a blank state (accounts, feeds, articles, notifiers) without touching the source code, .env or Docker images — useful to start over on a clean instance.',
|
||||||
|
|
||||||
|
// Auto-update
|
||||||
|
'autoupdate_title' => 'Automatic updates',
|
||||||
|
'autoupdate_text' => 'The auto-update.sh script periodically checks for new commits on the repository and only redeploys when needed, via a scheduled task on the server (never from the application container).',
|
||||||
|
'autoupdate_comment_install' => 'checks every 30 minutes',
|
||||||
|
'autoupdate_comment_status' => 'shows status and recent logs',
|
||||||
|
'autoupdate_comment_uninstall' => 'disables automatic updates',
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
'seo_home_title' => 'BloomFeed — Self-hosted, open-source RSS aggregator',
|
||||||
|
'seo_selfhost_title' => 'Self-host BloomFeed — Docker installation guide',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'settings' => 'Ajustes',
|
'settings' => 'Ajustes',
|
||||||
'logout' => 'Cerrar sesión',
|
'logout' => 'Cerrar sesión',
|
||||||
'change_theme' => 'Cambiar de tema',
|
'change_theme' => 'Cambiar de tema',
|
||||||
|
'nav_menu' => 'Menú',
|
||||||
'language' => 'Idioma',
|
'language' => 'Idioma',
|
||||||
'self_hosting' => 'Autoalojamiento',
|
'self_hosting' => 'Autoalojamiento',
|
||||||
'login' => 'Iniciar sesión',
|
'login' => 'Iniciar sesión',
|
||||||
@@ -140,6 +141,7 @@ return [
|
|||||||
'config_catalog' => 'El catálogo de fuentes integrado se personaliza editando resources/data/bloomflux.json y volviendo a desplegar.',
|
'config_catalog' => 'El catálogo de fuentes integrado se personaliza editando resources/data/bloomflux.json y volviendo a desplegar.',
|
||||||
'update_title' => 'Actualizar',
|
'update_title' => 'Actualizar',
|
||||||
'update_text' => 'Simplemente vuelve a ejecutar el script de despliegue: recupera los últimos cambios y reconstruye la pila sin tocar tus datos.',
|
'update_text' => 'Simplemente vuelve a ejecutar el script de despliegue: recupera los últimos cambios y reconstruye la pila sin tocar tus datos.',
|
||||||
|
'backup_note' => 'Antes de cada redespliegue, la base de datos se respalda automáticamente en backups/ (se conservan las últimas 10 copias). Si el respaldo falla, la actualización se cancela para no arriesgar nunca tus datos.',
|
||||||
'uninstall_title' => 'Desinstalar',
|
'uninstall_title' => 'Desinstalar',
|
||||||
'uninstall_text' => 'El script uninstall.sh detiene y elimina limpiamente la pila Docker. Pide confirmación explícita antes de borrar tus datos y nunca elimina el código fuente sin una confirmación separada.',
|
'uninstall_text' => 'El script uninstall.sh detiene y elimina limpiamente la pila Docker. Pide confirmación explícita antes de borrar tus datos y nunca elimina el código fuente sin una confirmación separada.',
|
||||||
'source_title' => 'Código fuente',
|
'source_title' => 'Código fuente',
|
||||||
@@ -191,4 +193,15 @@ return [
|
|||||||
|
|
||||||
'reset_title' => 'Reiniciar',
|
'reset_title' => 'Reiniciar',
|
||||||
'reset_text' => 'El script reset.sh restablece la instancia a un estado en blanco (cuentas, fuentes, artículos, notificadores) sin tocar el código fuente, el .env ni las imágenes Docker — útil para empezar de cero.',
|
'reset_text' => 'El script reset.sh restablece la instancia a un estado en blanco (cuentas, fuentes, artículos, notificadores) sin tocar el código fuente, el .env ni las imágenes Docker — útil para empezar de cero.',
|
||||||
|
|
||||||
|
// Actualización automática
|
||||||
|
'autoupdate_title' => 'Actualizaciones automáticas',
|
||||||
|
'autoupdate_text' => 'El script auto-update.sh comprueba periódicamente si hay nuevos commits en el repositorio y solo vuelve a desplegar cuando es necesario, mediante una tarea programada en el servidor (nunca desde el contenedor de la aplicación).',
|
||||||
|
'autoupdate_comment_install' => 'comprueba cada 30 minutos',
|
||||||
|
'autoupdate_comment_status' => 'muestra el estado y los últimos registros',
|
||||||
|
'autoupdate_comment_uninstall' => 'desactiva las actualizaciones automáticas',
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
'seo_home_title' => 'BloomFeed — Agregador RSS autoalojado y de código abierto',
|
||||||
|
'seo_selfhost_title' => 'Autoalojar BloomFeed — guía de instalación con Docker',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ return [
|
|||||||
'settings' => 'Paramètres',
|
'settings' => 'Paramètres',
|
||||||
'logout' => 'Déconnexion',
|
'logout' => 'Déconnexion',
|
||||||
'change_theme' => 'Changer de thème',
|
'change_theme' => 'Changer de thème',
|
||||||
|
'nav_menu' => 'Menu',
|
||||||
'language' => 'Langue',
|
'language' => 'Langue',
|
||||||
'self_hosting' => 'Self-hosting',
|
'self_hosting' => 'Self-hosting',
|
||||||
'login' => 'Se connecter',
|
'login' => 'Se connecter',
|
||||||
@@ -140,6 +141,7 @@ return [
|
|||||||
'config_catalog' => "Le catalogue de flux intégré se personnalise en éditant resources/data/bloomflux.json puis en redéployant.",
|
'config_catalog' => "Le catalogue de flux intégré se personnalise en éditant resources/data/bloomflux.json puis en redéployant.",
|
||||||
'update_title' => 'Mettre à jour',
|
'update_title' => 'Mettre à jour',
|
||||||
'update_text' => 'Relancez simplement le script de déploiement : il récupère les derniers changements et reconstruit la stack sans toucher à vos données.',
|
'update_text' => 'Relancez simplement le script de déploiement : il récupère les derniers changements et reconstruit la stack sans toucher à vos données.',
|
||||||
|
'backup_note' => 'Avant chaque redéploiement, la base de données est automatiquement sauvegardée dans backups/ (les 10 dernières sauvegardes sont conservées). Si la sauvegarde échoue, la mise à jour est annulée pour ne jamais risquer de perdre des données.',
|
||||||
'uninstall_title' => 'Désinstaller',
|
'uninstall_title' => 'Désinstaller',
|
||||||
'uninstall_text' => "Le script uninstall.sh arrête et supprime proprement la stack Docker. Il demande une confirmation explicite avant de supprimer vos données et ne supprime jamais le code source sans confirmation séparée.",
|
'uninstall_text' => "Le script uninstall.sh arrête et supprime proprement la stack Docker. Il demande une confirmation explicite avant de supprimer vos données et ne supprime jamais le code source sans confirmation séparée.",
|
||||||
'source_title' => 'Code source',
|
'source_title' => 'Code source',
|
||||||
@@ -191,4 +193,15 @@ return [
|
|||||||
|
|
||||||
'reset_title' => 'Réinitialiser',
|
'reset_title' => 'Réinitialiser',
|
||||||
'reset_text' => "Le script reset.sh remet l'instance à zéro (comptes, flux, articles, notifieurs) sans toucher au code, au .env ni aux images Docker — utile pour repartir sur une instance vierge.",
|
'reset_text' => "Le script reset.sh remet l'instance à zéro (comptes, flux, articles, notifieurs) sans toucher au code, au .env ni aux images Docker — utile pour repartir sur une instance vierge.",
|
||||||
|
|
||||||
|
// Mise à jour automatique
|
||||||
|
'autoupdate_title' => 'Mise à jour automatique',
|
||||||
|
'autoupdate_text' => "Le script auto-update.sh vérifie périodiquement les nouveaux commits sur le dépôt et ne redéploie que si nécessaire, via une tâche planifiée sur le serveur (jamais depuis le conteneur applicatif).",
|
||||||
|
'autoupdate_comment_install' => 'active la vérification toutes les 30 min',
|
||||||
|
'autoupdate_comment_status' => 'affiche l\'état et les derniers logs',
|
||||||
|
'autoupdate_comment_uninstall' => 'désactive la mise à jour automatique',
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
'seo_home_title' => 'BloomFeed — Agrégateur RSS auto-hébergé et open-source',
|
||||||
|
'seo_selfhost_title' => "Auto-héberger BloomFeed — guide d'installation Docker",
|
||||||
];
|
];
|
||||||
|
|||||||
+74
-9
@@ -224,20 +224,85 @@ h1, h2, h3 {
|
|||||||
color: var(--accent-contrast);
|
color: var(--accent-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.navbar-toggle {
|
||||||
.navbar {
|
display: none;
|
||||||
padding: 12px 16px;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggle-bar {
|
||||||
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--text);
|
||||||
|
transition: transform var(--t-fast), opacity var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggle.is-active .navbar-toggle-bar:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggle.is-active .navbar-toggle-bar:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-toggle.is-active .navbar-toggle-bar:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.navbar-toggle {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-links {
|
.navbar-links {
|
||||||
gap: 8px;
|
display: none;
|
||||||
flex-wrap: wrap;
|
position: absolute;
|
||||||
justify-content: flex-end;
|
top: 100%;
|
||||||
row-gap: 6px;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--space-3) 16px calc(var(--space-4) + 4px);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-self-hosting {
|
.navbar-links.is-open {
|
||||||
display: none;
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links a,
|
||||||
|
.navbar-links form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links a {
|
||||||
|
padding: 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links a.btn,
|
||||||
|
.navbar-links form button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-links .locale-form,
|
||||||
|
.navbar-links .theme-toggle {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
(function () {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var toggle = document.querySelector('[data-navbar-toggle]');
|
||||||
|
if (!toggle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var links = document.getElementById(toggle.getAttribute('aria-controls'));
|
||||||
|
if (!links) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
var isOpen = links.classList.toggle('is-open');
|
||||||
|
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||||
|
toggle.classList.toggle('is-active', isOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape' && links.classList.contains('is-open')) {
|
||||||
|
links.classList.remove('is-open');
|
||||||
|
toggle.setAttribute('aria-expanded', 'false');
|
||||||
|
toggle.classList.remove('is-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
@extends('layouts.marketing', ['title' => 'BloomFeed'])
|
@extends('layouts.marketing', [
|
||||||
|
'title' => __('app.seo_home_title'),
|
||||||
|
'description' => __('app.meta_description'),
|
||||||
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
// Screenshots live under images/screenshots/{locale}/ so each language can show its own
|
// Screenshots live under images/screenshots/{locale}/ so each language can show its own
|
||||||
@@ -129,4 +132,30 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="{{ asset('js/screenshot-gallery.js') }}" defer></script>
|
<script src="{{ asset('js/screenshot-gallery.js') }}" defer></script>
|
||||||
|
|
||||||
|
@push('structured-data')
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "BloomFeed",
|
||||||
|
"applicationCategory": "CommunicationApplication",
|
||||||
|
"operatingSystem": "Linux (Docker)",
|
||||||
|
"description": {!! json_encode(__('app.meta_description')) !!},
|
||||||
|
"url": {!! json_encode(url('/')) !!},
|
||||||
|
"image": {!! json_encode(asset('images/logo.png')) !!},
|
||||||
|
"license": "https://opensource.org/licenses/MIT",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "USD"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "BloomFeed",
|
||||||
|
"url": {!! json_encode(url('/self-hosting')) !!}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
||||||
|
<script src="{{ asset('js/navbar-toggle.js') }}" defer></script>
|
||||||
<script src="{{ asset('js/app.js') }}" defer></script>
|
<script src="{{ asset('js/app.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,9 +5,27 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<title>{{ $title ?? 'BloomFeed' }}</title>
|
<title>{{ $title ?? 'BloomFeed' }}</title>
|
||||||
<meta name="description" content="{{ __('app.meta_description') }}">
|
<meta name="description" content="{{ $description ?? __('app.meta_description') }}">
|
||||||
|
<link rel="canonical" href="{{ url()->current() }}">
|
||||||
<link rel="icon" href="{{ asset('images/icon.png') }}">
|
<link rel="icon" href="{{ asset('images/icon.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
|
<link rel="apple-touch-icon" href="{{ asset('images/icon.png') }}">
|
||||||
|
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:site_name" content="BloomFeed">
|
||||||
|
<meta property="og:locale" content="{{ str_replace('-', '_', app()->getLocale()) }}">
|
||||||
|
<meta property="og:title" content="{{ $title ?? 'BloomFeed' }}">
|
||||||
|
<meta property="og:description" content="{{ $description ?? __('app.meta_description') }}">
|
||||||
|
<meta property="og:url" content="{{ url()->current() }}">
|
||||||
|
<meta property="og:image" content="{{ asset('images/logo.png') }}">
|
||||||
|
<meta property="og:image:width" content="1917">
|
||||||
|
<meta property="og:image:height" content="544">
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="{{ $title ?? 'BloomFeed' }}">
|
||||||
|
<meta name="twitter:description" content="{{ $description ?? __('app.meta_description') }}">
|
||||||
|
<meta name="twitter:image" content="{{ asset('images/logo.png') }}">
|
||||||
|
|
||||||
|
@stack('structured-data')
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||||
@@ -27,7 +45,12 @@
|
|||||||
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
|
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
|
||||||
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
|
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
|
||||||
</a>
|
</a>
|
||||||
<div class="navbar-links">
|
<button type="button" class="navbar-toggle" data-navbar-toggle aria-expanded="false" aria-controls="navbarLinks" title="{{ __('app.nav_menu') }}">
|
||||||
|
<span class="navbar-toggle-bar"></span>
|
||||||
|
<span class="navbar-toggle-bar"></span>
|
||||||
|
<span class="navbar-toggle-bar"></span>
|
||||||
|
</button>
|
||||||
|
<div class="navbar-links" id="navbarLinks">
|
||||||
<a href="{{ route('self-hosting') }}" class="nav-self-hosting {{ request()->routeIs('self-hosting') ? 'active' : '' }}">{{ __('app.self_hosting') }}</a>
|
<a href="{{ route('self-hosting') }}" class="nav-self-hosting {{ request()->routeIs('self-hosting') ? 'active' : '' }}">{{ __('app.self_hosting') }}</a>
|
||||||
@include('partials.locale-selector')
|
@include('partials.locale-selector')
|
||||||
<button type="button" class="theme-toggle" data-theme-toggle title="{{ __('app.change_theme') }}">◐</button>
|
<button type="button" class="theme-toggle" data-theme-toggle title="{{ __('app.change_theme') }}">◐</button>
|
||||||
@@ -49,6 +72,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
<script src="{{ asset('js/theme-toggle.js') }}" defer></script>
|
||||||
|
<script src="{{ asset('js/navbar-toggle.js') }}" defer></script>
|
||||||
<script src="{{ asset('js/scroll-reveal.js') }}" defer></script>
|
<script src="{{ asset('js/scroll-reveal.js') }}" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
|
<img src="{{ asset('images/logo.png') }}" alt="BloomFeed" class="brand-logo brand-logo-light">
|
||||||
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
|
<img src="{{ asset('images/logo-dark.png') }}" alt="BloomFeed" class="brand-logo brand-logo-dark">
|
||||||
</a>
|
</a>
|
||||||
<div class="navbar-links">
|
<button type="button" class="navbar-toggle" data-navbar-toggle aria-expanded="false" aria-controls="navbarLinks" title="{{ __('app.nav_menu') }}">
|
||||||
|
<span class="navbar-toggle-bar"></span>
|
||||||
|
<span class="navbar-toggle-bar"></span>
|
||||||
|
<span class="navbar-toggle-bar"></span>
|
||||||
|
</button>
|
||||||
|
<div class="navbar-links" id="navbarLinks">
|
||||||
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">{{ __('app.articles') }}</a>
|
<a href="{{ route('dashboard') }}" class="{{ request()->routeIs('dashboard') ? 'active' : '' }}">{{ __('app.articles') }}</a>
|
||||||
<a href="{{ route('catalog') }}" class="{{ request()->routeIs('catalog') ? 'active' : '' }}">{{ __('app.catalog') }}</a>
|
<a href="{{ route('catalog') }}" class="{{ request()->routeIs('catalog') ? 'active' : '' }}">{{ __('app.catalog') }}</a>
|
||||||
<a href="{{ route('feeds.index') }}" class="{{ request()->routeIs('feeds.*') ? 'active' : '' }}">{{ __('app.my_feeds') }}</a>
|
<a href="{{ route('feeds.index') }}" class="{{ request()->routeIs('feeds.*') ? 'active' : '' }}">{{ __('app.my_feeds') }}</a>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
@extends('layouts.marketing', ['title' => __('app.selfhost_title').' — BloomFeed'])
|
@extends('layouts.marketing', [
|
||||||
|
'title' => __('app.seo_selfhost_title'),
|
||||||
|
'description' => __('app.selfhost_intro'),
|
||||||
|
])
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<section class="docs-shell">
|
<section class="docs-shell">
|
||||||
@@ -45,6 +48,15 @@ cd bloomfeed</pre>
|
|||||||
<h2>{{ __('app.update_title') }}</h2>
|
<h2>{{ __('app.update_title') }}</h2>
|
||||||
<p>{{ __('app.update_text') }}</p>
|
<p>{{ __('app.update_text') }}</p>
|
||||||
<pre class="code-block">bash scriptsite.sh</pre>
|
<pre class="code-block">bash scriptsite.sh</pre>
|
||||||
|
<p>{{ __('app.backup_note') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-2">
|
||||||
|
<h2>{{ __('app.autoupdate_title') }}</h2>
|
||||||
|
<p>{{ __('app.autoupdate_text') }}</p>
|
||||||
|
<pre class="code-block">bash auto-update.sh --install # {{ __('app.autoupdate_comment_install') }}
|
||||||
|
bash auto-update.sh --status # {{ __('app.autoupdate_comment_status') }}
|
||||||
|
bash auto-update.sh --uninstall # {{ __('app.autoupdate_comment_uninstall') }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Route;
|
|||||||
|
|
||||||
Route::get('/', [PageController::class, 'home'])->name('home');
|
Route::get('/', [PageController::class, 'home'])->name('home');
|
||||||
Route::get('/self-hosting', [PageController::class, 'selfHosting'])->name('self-hosting');
|
Route::get('/self-hosting', [PageController::class, 'selfHosting'])->name('self-hosting');
|
||||||
|
Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots');
|
||||||
|
Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap');
|
||||||
Route::post('/locale', [SettingsController::class, 'updateLocale'])->name('locale.update');
|
Route::post('/locale', [SettingsController::class, 'updateLocale'])->name('locale.update');
|
||||||
|
|
||||||
Route::middleware('guest')->group(function () {
|
Route::middleware('guest')->group(function () {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
|||||||
REPO_URL="https://git.eldorianet.work/esteban/BloomFeed-RSS"
|
REPO_URL="https://git.eldorianet.work/esteban/BloomFeed-RSS"
|
||||||
APP_DIR="/opt/bloomfeed"
|
APP_DIR="/opt/bloomfeed"
|
||||||
HOST_PORT="7081"
|
HOST_PORT="7081"
|
||||||
|
BACKUP_RETENTION="10"
|
||||||
|
|
||||||
echo "==> Killing any process listening on port ${HOST_PORT}"
|
echo "==> Killing any process listening on port ${HOST_PORT}"
|
||||||
PIDS="$(sudo lsof -ti tcp:${HOST_PORT} || true)"
|
PIDS="$(sudo lsof -ti tcp:${HOST_PORT} || true)"
|
||||||
@@ -48,6 +49,27 @@ if grep -q '^DB_ROOT_PASSWORD=$' .env; then
|
|||||||
fi
|
fi
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
|
|
||||||
|
echo "==> Backing up the database before redeploying (if a previous instance is running)"
|
||||||
|
BACKUP_DIR="${APP_DIR}/backups"
|
||||||
|
if [[ -n "$(sudo docker compose ps -q db 2>/dev/null)" ]]; then
|
||||||
|
sudo mkdir -p "${BACKUP_DIR}"
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/bloomfeed-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
if sudo docker compose exec -T db sh -c 'exec mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" --all-databases' | gzip > "${BACKUP_FILE}"; then
|
||||||
|
sudo chmod 600 "${BACKUP_FILE}"
|
||||||
|
echo "==> Backup saved: ${BACKUP_FILE}"
|
||||||
|
echo "==> Pruning old backups (keeping the last ${BACKUP_RETENTION})"
|
||||||
|
(sudo find "${BACKUP_DIR}" -maxdepth 1 -name 'bloomfeed-*.sql.gz' -print | sort | head -n -"${BACKUP_RETENTION}" | xargs -r sudo rm -f) || echo "WARNING: pruning old backups failed (non-fatal, continuing) — check ${BACKUP_DIR} manually." >&2
|
||||||
|
else
|
||||||
|
sudo rm -f "${BACKUP_FILE}"
|
||||||
|
echo "ERROR: database backup failed — aborting the update so no data is put at risk." >&2
|
||||||
|
echo " Check 'sudo docker compose logs db' and retry manually." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "==> No running database container found (fresh install) — skipping backup"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Stopping the previous Docker Compose stack if present"
|
echo "==> Stopping the previous Docker Compose stack if present"
|
||||||
sudo docker compose down --remove-orphans || true
|
sudo docker compose down --remove-orphans || true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user