feat(ghost): add news publishing service

This commit is contained in:
Eduardo David Paredes Vara
2026-08-09 16:56:50 +00:00
parent 0ccfa73344
commit 927d4007f6
9 changed files with 636 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
TZ=Europe/Madrid
GHOST_IMAGE=ghost:6-alpine
GHOST_DOMAIN=news.sherlockhomeless.net
GHOST_CONTENT_PATH=/opt/ghost/content
GHOST_DB_IMAGE=mysql:8.4
GHOST_DB_DATA_PATH=/opt/ghost/mysql
GHOST_DB_NAME=ghost
GHOST_DB_USER=ghost
GHOST_DB_PASSWORD=CHANGE_ME
GHOST_DB_ROOT_PASSWORD=CHANGE_ME
GHOST_MAIL_FROM="News <news@thehomelesssherlock.com>"
GHOST_OPS_ALLOWED_EMAIL=you@example.com
GHOST_CONTENT_API_KEY=CHANGE_ME
TRAEFIK_DOCKER_NETWORK=proxy
TRAEFIK_ENTRYPOINT_SECURE=websecure
TRAEFIK_CERTRESOLVER=letsencrypt
+36
View File
@@ -0,0 +1,36 @@
# Ghost News
Ghost publica `news.sherlockhomeless.net` como archivo de informes diarios.
## Despliegue
```bash
cd ghost
docker compose --env-file .env config
docker compose --env-file .env up -d
```
El dominio se expone por Traefik en la red `proxy` y queda protegido con `crowdsec-bouncer@file`.
La administracion queda bajo el login propio de Ghost en `/ghost/`.
## Primer acceso
Abre:
```text
https://news.sherlockhomeless.net/ghost/
```
Completa el usuario administrador inicial de Ghost.
## Integracion futura con n8n
El flujo recomendado es:
```text
n8n genera informe completo
-> publica post en Ghost por Admin API
-> envia email/Telegram con resumen + enlace
```
Para publicar desde n8n no hace falta atravesar Traefik: puede llamar al servicio interno `http://ghost:2368` si se conecta la red correspondiente o se usa una ruta interna dedicada.
+108
View File
@@ -0,0 +1,108 @@
services:
ghost-db:
image: ${GHOST_DB_IMAGE}
container_name: ghost-db
restart: unless-stopped
labels:
prune.protect: "true"
environment:
MYSQL_ROOT_PASSWORD: ${GHOST_DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${GHOST_DB_NAME}
MYSQL_USER: ${GHOST_DB_USER}
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
TZ: ${TZ}
volumes:
- ${GHOST_DB_DATA_PATH}:/var/lib/mysql:Z
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u${GHOST_DB_USER} -p${GHOST_DB_PASSWORD} --silent"]
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
networks:
- ghost_internal
ghost:
image: ${GHOST_IMAGE}
container_name: ghost
restart: unless-stopped
depends_on:
ghost-db:
condition: service_healthy
environment:
NODE_ENV: production
TZ: ${TZ}
url: https://${GHOST_DOMAIN}
database__client: mysql
database__connection__host: ghost-db
database__connection__user: ${GHOST_DB_USER}
database__connection__password: ${GHOST_DB_PASSWORD}
database__connection__database: ${GHOST_DB_NAME}
mail__transport: SMTP
mail__from: ${GHOST_MAIL_FROM}
mail__options__host: mail-relay
mail__options__port: 587
mail__options__secure: "false"
mail__options__ignoreTLS: "true"
volumes:
- ${GHOST_CONTENT_PATH}:/var/lib/ghost/content:Z
networks:
- ghost_internal
- proxy
- mail_internal
labels:
prune.protect: "true"
traefik.enable: "true"
traefik.docker.network: "${TRAEFIK_DOCKER_NETWORK}"
traefik.http.routers.ghost.rule: "Host(`${GHOST_DOMAIN}`)"
traefik.http.routers.ghost.entrypoints: "${TRAEFIK_ENTRYPOINT_SECURE}"
traefik.http.routers.ghost.tls: "true"
traefik.http.routers.ghost.tls.certresolver: "${TRAEFIK_CERTRESOLVER}"
traefik.http.routers.ghost.service: "ghost"
traefik.http.routers.ghost.middlewares: "crowdsec-bouncer@file"
traefik.http.services.ghost.loadbalancer.server.port: "2368"
homepage.group: "Productividad"
homepage.name: "News"
homepage.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ghost.svg"
homepage.description: "Briefing diario y archivo de informes"
homepage.href: "https://${GHOST_DOMAIN}/"
homepage.siteMonitor: "http://ghost:2368/"
homepage.weight: "570"
ghost-ops-gate:
image: python:3.12-alpine
container_name: ghost-ops-gate
restart: unless-stopped
depends_on:
- ghost
environment:
OPS_ALLOWED_EMAIL: ${GHOST_OPS_ALLOWED_EMAIL}
GHOST_CONTENT_API_KEY: ${GHOST_CONTENT_API_KEY}
command: ["python", "/app/ops_gate.py"]
volumes:
- ./ops-gate:/app:ro,Z
networks:
- ghost_internal
- proxy
labels:
prune.protect: "true"
traefik.enable: "true"
traefik.docker.network: "${TRAEFIK_DOCKER_NETWORK}"
traefik.http.routers.ghost-ops-gate.rule: "Host(`${GHOST_DOMAIN}`) && (Path(`/ops`) || Path(`/ops/`))"
traefik.http.routers.ghost-ops-gate.entrypoints: "${TRAEFIK_ENTRYPOINT_SECURE}"
traefik.http.routers.ghost-ops-gate.tls: "true"
traefik.http.routers.ghost-ops-gate.tls.certresolver: "${TRAEFIK_CERTRESOLVER}"
traefik.http.routers.ghost-ops-gate.priority: "100"
traefik.http.routers.ghost-ops-gate.middlewares: "crowdsec-bouncer@file"
traefik.http.routers.ghost-ops-gate.service: "ghost-ops-gate"
traefik.http.services.ghost-ops-gate.loadbalancer.server.port: "8080"
networks:
ghost_internal:
driver: bridge
labels:
prune.protect: "true"
mail_internal:
external: true
proxy:
external: true
+113
View File
@@ -0,0 +1,113 @@
import json
import os
import sys
from html import escape as html_escape
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
GHOST_URL = "http://ghost:2368"
ALLOWED_EMAIL = os.environ["OPS_ALLOWED_EMAIL"].strip().lower()
CONTENT_API_KEY = os.environ["GHOST_CONTENT_API_KEY"].strip()
class Handler(BaseHTTPRequestHandler):
def log_safe(self, **fields):
print(json.dumps(fields, sort_keys=True), file=sys.stderr, flush=True)
def redirect_to_ghost_404(self):
self.send_response(302)
self.send_header("Location", "/404/")
self.send_header("Cache-Control", "no-store")
self.end_headers()
def do_GET(self):
if self.path not in ("/ops", "/ops/"):
self.redirect_to_ghost_404()
return
headers = {
"Host": "news.sherlockhomeless.net",
"X-Forwarded-Proto": "https",
"User-Agent": self.headers.get("User-Agent", "ghost-ops-gate"),
}
cookie = self.headers.get("Cookie")
if cookie:
headers["Cookie"] = cookie
cookie_names = []
if cookie:
cookie_names = [part.split("=", 1)[0].strip() for part in cookie.split(";") if "=" in part]
try:
member_req = Request(f"{GHOST_URL}/members/api/member/", headers=headers)
with urlopen(member_req, timeout=10) as member_response:
self.log_safe(path=self.path, cookie_names=cookie_names, member_status=member_response.status)
if member_response.status != 200:
self.redirect_to_ghost_404()
return
member_payload = json.loads(member_response.read().decode("utf-8") or "{}")
member = member_payload.get("member") or member_payload
self.log_safe(path=self.path, member_payload_keys=sorted(member_payload.keys()), member_keys=sorted(member.keys()))
member_email = str(member.get("email", "")).lower()
self.log_safe(path=self.path, member_email_present=bool(member_email), member_email_match=member_email == ALLOWED_EMAIL)
if member_email != ALLOWED_EMAIL:
self.redirect_to_ghost_404()
return
req = Request(
f"{GHOST_URL}/ghost/api/content/posts/"
f"?key={CONTENT_API_KEY}"
"&filter=tag:hash-private-ops"
"&fields=title,slug,url,published_at,custom_excerpt"
"&order=published_at%20desc"
"&limit=50",
headers=headers,
)
with urlopen(req, timeout=10) as response:
posts_payload = json.loads(response.read().decode("utf-8") or "{}")
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
self.redirect_to_ghost_404()
return
posts = posts_payload.get("posts") or []
if not posts:
self.redirect_to_ghost_404()
return
items = "\n".join(
f'<li style="margin:14px 0;"><a href="/ops/{post["slug"]}/" '
f'style="color:#1d4ed8;text-decoration:none;font-weight:700;">'
f'{html_escape(post["title"])}</a>'
f'<div style="color:#64748b;font-size:13px;margin-top:4px;">'
f'{html_escape((post.get("published_at") or "")[:10])}</div></li>'
for post in posts
)
body = f"""<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Private Ops</title>
</head>
<body style="margin:0;background:#f8fafc;color:#111827;font-family:Arial,Helvetica,sans-serif;">
<main style="max-width:860px;margin:0 auto;padding:42px 20px;">
<h1 style="font-size:32px;line-height:1.2;margin:0 0 22px;">Private Ops</h1>
<ul style="list-style:none;padding:0;margin:0;">{items}</ul>
</main>
</body>
</html>""".encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "private, no-store")
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
return
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
+140
View File
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="{{@site.locale}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{asset "built/screen.css"}}">
{{#is "post"}}
{{#post}}
{{#has tag="#private-ops"}}
{{#if access}}
<title>{{meta_title page=(t " (Page %)")}}</title>
{{ghost_head}}
{{else}}
<title>No encontrado</title>
<meta name="robots" content="noindex,nofollow">
{{/if}}
{{else}}
<title>{{meta_title page=(t " (Page %)")}}</title>
{{ghost_head}}
{{/has}}
{{/post}}
{{else}}
<title>{{meta_title page=(t " (Page %)")}}</title>
{{ghost_head}}
{{/is}}
</head>
<body class="{{body_class}}{{{block "body_class"}}} is-head-{{#match @custom.navigation_layout "Logo on the left"}}left-logo{{else match @custom.navigation_layout "Logo in the middle"}}middle-logo{{else}}stacked{{/match}}{{#match @custom.title_font "=" "Elegant serif"}} has-serif-title{{/match}}{{#match @custom.body_font "=" "Elegant serif"}} has-serif-body{{/match}}{{#is "tag"}}{{#if tag.feature_image}} is-head-transparent{{/if}}{{/is}}{{#match @custom.header_style "Accent color"}} is-head-brand{{/match}}{{#match @custom.header_style "Dark"}} is-head-dark{{/match}}">
<div class="gh-site">
<header id="gh-head" class="gh-head gh-outer">
<div class="gh-head-inner gh-inner">
<div class="gh-head-brand">
<div class="gh-head-brand-wrapper">
{{#is "home"}}<h1 class="gh-head-logo">{{/is}}
<a {{^is "home"}}class="gh-head-logo"{{/is}} href="{{@site.url}}">
{{#if @site.logo}}
<img src="{{@site.logo}}" alt="{{@site.title}}">
{{#if @custom.white_publication_logo_for_transparent_header}}
<img src="{{@custom.white_publication_logo_for_transparent_header}}" alt="{{@site.title}}">
{{/if}}
{{else}}
{{@site.title}}
{{/if}}
</a>
{{#is "home"}}</h1>{{/is}}
</div>
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
<button class="gh-burger" aria-label="{{t "Toggle menu"}}"></button>
</div>
<nav class="gh-head-menu">
{{navigation}}
{{#unless @site.members_enabled}}
{{#match @custom.navigation_layout "Stacked"}}
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
{{/match}}
{{/unless}}
</nav>
<div class="gh-head-actions">
{{#unless @site.members_enabled}}
{{^match @custom.navigation_layout "Stacked"}}
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
{{/match}}
{{else}}
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
<div class="gh-head-members">
{{#unless @member}}
{{#unless @site.members_invite_only}}
<a class="gh-head-link" href="#/portal/signin" data-portal="signin">{{t "Sign in"}}</a>
<a class="gh-head-btn gh-btn gh-primary-btn" href="#/portal/signup" data-portal="signup">{{t "Subscribe"}}</a>
{{else}}
<a class="gh-head-btn gh-btn gh-primary-btn" href="#/portal/signin" data-portal="signin">{{t "Sign in"}}</a>
{{/unless}}
{{else}}
<a class="gh-head-btn gh-btn gh-primary-btn" href="#/portal/account" data-portal="account">{{t "Account"}}</a>
{{/unless}}
</div>
{{/unless}}
</div>
</div>
</header>
{{{body}}}
<footer class="gh-foot gh-outer">
<div class="gh-foot-inner gh-inner">
{{#if @site.members_enabled}}
{{#unless @member}}
<section class="gh-subscribe">
<h3 class="gh-subscribe-title">{{t "Subscribe to {sitetitle}" sitetitle=@site.title}}</h3>
{{#if @custom.email_signup_text}}
<div class="gh-subscribe-description">{{@custom.email_signup_text}}</div>
{{/if}}
<button class="gh-subscribe-btn gh-btn" data-portal="signup">{{> "icons/email"}} {{t "Subscribe now"}}</button>
</section>
{{/unless}}
{{/if}}
<nav class="gh-foot-menu">
{{navigation type="secondary"}}
</nav>
<div class="gh-social-links">
{{#social_accounts @site}}
<a href="{{href}}" target="_blank" rel="noopener" aria-label="{{name}}">
{{#> (concat "icons/" type)}}
{{!-- Fallback when no per-platform icon partial exists --}}
<span>{{name}}</span>
{{/undefined}}
</a>
{{/social_accounts}}
</div>
<div class="gh-copyright">
{{#unless @custom.footer_text}}
{{@site.title}} © {{date format="YYYY"}}. {{{t "Powered by {ghostlink}" ghostlink="<a href=\"https://ghost.org/\" target=\"_blank\" rel=\"noopener\">Ghost</a>"}}}
{{else}}
{{@custom.footer_text}}
{{/unless}}
</div>
</div>
</footer>
</div>
{{#is "post, page"}}
{{> "pswp"}}
{{/is}}
<script src="{{asset "built/main.min.js"}}"></script>
{{ghost_foot}}
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
{{!< default}}
<main class="gh-main gh-outer">
<div class="gh-inner">
{{#get "posts" filter="tag:hash-private-ops" include="tags,authors" limit="20" as |ops_posts|}}
{{#foreach ops_posts limit="1"}}
{{#if access}}
<section class="gh-pagehead">
<h1 class="gh-pagehead-title">Private Ops</h1>
</section>
<div class="gh-topic gh-topic-grid">
<div class="gh-topic-content gh-feed">
{{#foreach ops_posts}}
{{> "loop-grid" has_large_post=false}}
{{/foreach}}
</div>
</div>
{{else}}
<section class="gh-pagehead">
<h1 class="gh-pagehead-title">No encontrado</h1>
</section>
{{/if}}
{{else}}
<section class="gh-pagehead">
<h1 class="gh-pagehead-title">No encontrado</h1>
</section>
{{/foreach}}
{{/get}}
</div>
</main>
@@ -0,0 +1,89 @@
{{#has tag="#private-ops"}}
{{#if access}}
<article class="gh-card {{post_class}}{{#match has_large_post "!=" false}}{{#has index="0"}} large{{/has}}{{/match}}">
<a class="gh-card-link" href="{{url}}">
{{#if feature_image}}
<figure class="gh-card-image">
<img
srcset="{{img_url feature_image size="s"}} 300w,
{{img_url feature_image size="m"}} 720w,
{{img_url feature_image size="l"}} 960w,
{{img_url feature_image size="xl"}} 1200w,
{{img_url feature_image size="xxl"}} 2000w"
sizes="(max-width: 1200px) 100vw, 1200px"
src="{{img_url feature_image size="m"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
>
</figure>
{{/if}}
<div class="gh-card-wrapper">
<header class="gh-card-header">
<h3 class="gh-card-title">{{title}}</h3>
</header>
{{#if excerpt}}
{{#is "home"}}
{{#has index="0"}}
<div class="gh-card-excerpt">{{excerpt}}</div>
{{/has}}
{{else}}
<div class="gh-card-excerpt">{{excerpt}}</div>
{{/is}}
{{/if}}
<footer class="gh-card-footer">
<span class="gh-card-author">{{#foreach authors}}{{#if @first}}{{name}}{{else}}, {{name}}{{/if}}{{/foreach}}</span>
<time class="gh-card-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMM YYYY"}}</time>
{{#if @site.comments_enabled}}
{{comment_count class="gh-card-comments"}}
{{/if}}
</footer>
</div>
</a>
</article>
{{/if}}
{{else}}
<article class="gh-card {{post_class}}{{#match has_large_post "!=" false}}{{#has index="0"}} large{{/has}}{{/match}}">
<a class="gh-card-link" href="{{url}}">
{{#if feature_image}}
<figure class="gh-card-image">
<img
srcset="{{img_url feature_image size="s"}} 300w,
{{img_url feature_image size="m"}} 720w,
{{img_url feature_image size="l"}} 960w,
{{img_url feature_image size="xl"}} 1200w,
{{img_url feature_image size="xxl"}} 2000w"
sizes="(max-width: 1200px) 100vw, 1200px"
src="{{img_url feature_image size="m"}}"
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
>
</figure>
{{/if}}
<div class="gh-card-wrapper">
<header class="gh-card-header">
<h3 class="gh-card-title">{{title}}</h3>
</header>
{{#if excerpt}}
{{#is "home"}}
{{#has index="0"}}
<div class="gh-card-excerpt">{{excerpt}}</div>
{{/has}}
{{else}}
<div class="gh-card-excerpt">{{excerpt}}</div>
{{/is}}
{{/if}}
<footer class="gh-card-footer">
<span class="gh-card-author">{{#foreach authors}}{{#if @first}}{{name}}{{else}}, {{name}}{{/if}}{{/foreach}}</span>
<time class="gh-card-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMM YYYY"}}</time>
{{#if @site.comments_enabled}}
{{comment_count class="gh-card-comments"}}
{{/if}}
</footer>
</div>
</a>
</article>
{{/has}}
@@ -0,0 +1,15 @@
{{#has tag="#private-ops"}}
{{#if access}}
<article class="gh-card {{post_class}}">
<a class="gh-card-link" href="{{url}}">
<h3 class="gh-card-title">{{title}}</h3>
</a>
</article>
{{/if}}
{{else}}
<article class="gh-card {{post_class}}">
<a class="gh-card-link" href="{{url}}">
<h3 class="gh-card-title">{{title}}</h3>
</a>
</article>
{{/has}}
+83
View File
@@ -0,0 +1,83 @@
{{!< default}}
<main class="gh-main">
{{#post}}
{{#has tag="#private-ops"}}
{{#if access}}
<article class="gh-article {{post_class}}">
<header class="gh-article-header gh-canvas">
{{#if primary_tag}}
<a class="gh-article-tag" href="{{primary_tag.url}}">{{primary_tag.name}}</a>
{{/if}}
<h1 class="gh-article-title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="gh-article-excerpt">{{custom_excerpt}}</p>
{{/if}}
{{> "post-meta"}}
{{> "feature-image"}}
</header>
<section class="gh-content gh-canvas">
{{content}}
</section>
</article>
{{else}}
<article class="gh-article">
<header class="gh-article-header gh-canvas">
<h1 class="gh-article-title">No encontrado</h1>
</header>
</article>
{{/if}}
{{else}}
<article class="gh-article {{post_class}}">
<header class="gh-article-header gh-canvas">
{{#if primary_tag}}
<a class="gh-article-tag" href="{{primary_tag.url}}">{{primary_tag.name}}</a>
{{/if}}
<h1 class="gh-article-title">{{title}}</h1>
{{#if custom_excerpt}}
<p class="gh-article-excerpt">{{custom_excerpt}}</p>
{{/if}}
{{> "post-meta"}}
{{> "feature-image"}}
</header>
<section class="gh-content gh-canvas">
{{content}}
</section>
</article>
{{/has}}
{{/post}}
{{#post}}
{{#has tag="#private-ops"}}
{{#if access}}
{{> "related-posts"}}
{{/if}}
{{else}}
{{> "related-posts"}}
{{/has}}
{{/post}}
{{#post}}
{{#has tag="#private-ops"}}
{{#if access}}
{{> "comments"}}
{{/if}}
{{else}}
{{> "comments"}}
{{/has}}
{{/post}}
</main>