RESEARCH
Soledade
Tutorial completo para criar um blog textual estilo Nightfall/Nex/NPS
Versão: 1.0
Data: 2026-05-31
Autor do projeto: Pablo Murad
Objetivo: criar um blog minimalista, textual, acessível por navegador e também por terminal, inspirado em sistemas como Nightfall City, Nex, Gopher, Gemini e web antiga.
1. O que é o Soledade?
Soledade é um sistema de blog textual minimalista. Ele não tenta ser WordPress, Medium, Substack ou rede social. A ideia é outra:
- páginas simples;
- posts escritos em Markdown;
- publicação por terminal;
- acesso via navegador comum;
- leitura via protocolo textual próprio;
- pouca dependência;
- estética de internet antiga;
- controle total do servidor.
O sistema terá três camadas:
HTTPS / navegador -> site público normal
TCP porta 1900 -> leitura textual estilo Nex
TCP porta 1915 -> postagem textual estilo NPS
O fluxo ideal:
Você escreve um post -> envia por terminal -> servidor salva -> site é reconstruído -> post aparece na web e no terminal
2. Inspiração: Nightfall, Nex e NPS
A inspiração mais direta vem da Nightfall City:
- Site: https://nightfall.city/
- Cidadania: https://nightfall.city/citizenship/
- Manual: https://nightfall.city/citizenship/manual.txt
- Especificação Nex: https://nightfall.city/nex/info/specification.txt
- Especificação NPS: https://nightfall.city/nps/info/specification.txt
No Nightfall, o protocolo Nex serve documentos por TCP na porta 1900. O cliente envia um caminho, o servidor responde com texto ou binário e fecha a conexão.
Exemplo conceitual:
printf "nex/info/specification.txt\n" | nc nightfall.city 1900
O NPS é o serviço de postagem. Ele escuta na porta 1915. Você conecta, envia um caminho, uma chave e o conteúdo. Uma linha contendo apenas . encerra a transmissão.
Exemplo conceitual:
shore/usuario/index
CHAVE_SECRETA
conteúdo da página
.
O Soledade usará uma ideia parecida, mas adaptada para blog pessoal.
3. O que vamos construir
O Soledade terá:
- Gerador estático em Python: transforma Markdown em HTML.
- Site público servido por nginx ou Caddy:
https://blog.seudominio.com/. - Servidor de leitura textual: porta
1900, para ler conteúdo via terminal. - Servidor de postagem: porta
1915, para publicar/editar posts via terminal. - Token secreto: uma chave simples para autorizar publicações.
- systemd: para manter os serviços rodando no Linux.
- Firewall: para abrir apenas as portas necessárias.
4. Ferramentas recomendadas
Sistema operacional
Recomendado:
- Debian 12 ou Debian 13
- Ubuntu Server 24.04 LTS
- VPS simples com 1 GB RAM já basta
Links úteis:
- Debian: https://www.debian.org/
- Ubuntu Server: https://ubuntu.com/server
Linguagem
Use Python para a primeira versão. Não complique.
Links:
- Python: https://www.python.org/
- Documentação
socketserver: https://docs.python.org/3/library/socketserver.html - Documentação
http.server: https://docs.python.org/3/library/http.server.html
Markdown
Opções boas:
- Python-Markdown: https://python-markdown.github.io/
- PyPI Markdown: https://pypi.org/project/Markdown/
- Mistune: https://mistune.lepture.com/
- PyPI Mistune: https://pypi.org/project/mistune/
Para o Soledade, recomendo Python-Markdown pela simplicidade.
Servidor web
Duas opções:
nginx
Mais clássico, robusto, comum em servidores Linux.
- Documentação: https://nginx.org/en/docs/
- Reverse proxy: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
Caddy
Mais simples para HTTPS automático. Excelente para projetos pequenos.
- Site: https://caddyserver.com/
- HTTPS automático: https://caddy.guide/docs/automatic-https
Minha recomendação prática:
- Se você quer controle e padrão de mercado: nginx.
- Se quer velocidade e menos dor com certificado: Caddy.
HTTPS
Se usar nginx:
- Certbot: https://certbot.eff.org/
- Certbot no Debian: https://manpages.debian.org/bookworm/certbot/certbot.7.en.html
Se usar Caddy, ele já cuida disso automaticamente.
Segurança
Deploy e edição
- Git: https://git-scm.com/
- rsync: https://rsync.samba.org/
- Neovim: https://neovim.io/
- VS Code: https://code.visualstudio.com/
- Windows Terminal: https://github.com/microsoft/terminal
- Nmap/Ncat: https://nmap.org/ncat/
Geradores estáticos prontos, caso você desista de fazer do zero
Se em algum momento você quiser algo pronto:
- Hugo: https://gohugo.io/
- Zola: https://www.getzola.org/
- Eleventy: https://www.11ty.dev/
- Jekyll: https://jekyllrb.com/
Mas para o projeto Soledade, vamos fazer do zero porque o objetivo é aprender e ter controle.
5. Arquitetura final
/opt/soledade/
├── content/
│ ├── index.md
│ ├── about.md
│ └── posts/
│ └── primeiro-post.md
├── public/
│ ├── index.html
│ ├── about/
│ │ └── index.html
│ └── posts/
│ └── primeiro-post/
│ └── index.html
├── soledade/
│ ├── build.py
│ ├── nex_server.py
│ ├── post_server.py
│ └── config.py
├── templates/
│ └── base.html
├── secrets/
│ └── token.txt
├── logs/
└── venv/
Função de cada pasta
content/ -> textos originais em Markdown
public/ -> HTML gerado para a web
soledade/ -> código Python do sistema
templates/ -> layout HTML
secrets/ -> token privado de publicação
logs/ -> logs simples
venv/ -> ambiente virtual Python
6. Preparando o servidor
Entre no seu servidor Debian/Ubuntu:
ssh usuario@SEU_IP
Atualize:
sudo apt update
sudo apt upgrade -y
Instale pacotes básicos:
sudo apt install -y python3 python3-venv python3-pip nginx ufw git curl unzip
Crie usuário dedicado:
sudo adduser --system --group --home /opt/soledade soledade
Crie diretório:
sudo mkdir -p /opt/soledade
sudo chown -R soledade:soledade /opt/soledade
Entre como usuário do projeto:
sudo -u soledade -H bash
cd /opt/soledade
7. Criando a estrutura
mkdir -p content/posts public soledade templates secrets logs
python3 -m venv venv
source venv/bin/activate
pip install Markdown
Crie o token secreto:
openssl rand -hex 32 > secrets/token.txt
chmod 600 secrets/token.txt
cat secrets/token.txt
Guarde esse token. Ele será sua senha de publicação.
8. Arquivo de configuração
Crie:
nano soledade/config.py
Conteúdo:
from pathlib import Path
BASE_DIR = Path("/opt/soledade")
CONTENT_DIR = BASE_DIR / "content"
PUBLIC_DIR = BASE_DIR / "public"
TEMPLATE_FILE = BASE_DIR / "templates" / "base.html"
TOKEN_FILE = BASE_DIR / "secrets" / "token.txt"
LOG_DIR = BASE_DIR / "logs"
SITE_NAME = "Soledade"
SITE_DESCRIPTION = "Um blog textual, quieto e feito à mão."
BASE_URL = "https://blog.seudominio.com"
NEX_HOST = "0.0.0.0"
NEX_PORT = 1900
POST_HOST = "0.0.0.0"
POST_PORT = 1915
MAX_POST_BYTES = 128 * 1024
Troque https://blog.seudominio.com pelo seu domínio real.
9. Template HTML
Crie:
nano templates/base.html
Conteúdo:
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} - {{ site_name }}</title>
<meta name="description" content="{{ description }}">
<style>
:root {
color-scheme: dark;
--bg: #0b0d10;
--fg: #d8d4c8;
--muted: #8b867a;
--link: #9ccfd8;
--border: #2a2f35;
--code: #15191f;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
line-height: 1.65;
}
main { max-width: 760px; margin: 0 auto; padding: 48px 20px 80px; }
header { border-bottom: 1px solid var(--border); margin-bottom: 32px; padding-bottom: 16px; }
footer { border-top: 1px solid var(--border); margin-top: 48px; padding-top: 16px; color: var(--muted); font-size: 0.9rem; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3 { line-height: 1.25; }
pre, code { background: var(--code); }
pre { padding: 16px; overflow-x: auto; border: 1px solid var(--border); }
code { padding: 2px 4px; }
blockquote { border-left: 3px solid var(--border); margin-left: 0; padding-left: 16px; color: var(--muted); }
.site-title { font-size: 1.4rem; font-weight: bold; }
.site-description { color: var(--muted); }
</style>
</head>
<body>
<main>
<header>
<div class="site-title"><a href="/">{{ site_name }}</a></div>
<div class="site-description">{{ site_description }}</div>
</header>
<article>
{{ content }}
</article>
<footer>
Feito em texto. Servido por silêncio. Lido por navegador ou terminal.
</footer>
</main>
</body>
</html>
10. Conteúdo inicial
Crie:
nano content/index.md
Conteúdo:
# Soledade
Bem-vindo ao Soledade.
Este é um blog textual, mínimo e feito à mão.
Não há feed infinito.
Não há algoritmo.
Não há ruído.
Apenas páginas, caminhos e memória.
## Portas
- [Sobre](/about/)
- [Posts](/posts/)
## Leitura pelo terminal
Quando o protocolo textual estiver ativo:
```bash
printf "index.md\n" | ncat blog.seudominio.com 1900
```
Crie:
nano content/about.md
Conteúdo:
# Sobre
Soledade é um experimento de publicação textual.
A web ficou pesada demais. Este lugar escolhe o contrário:
- texto;
- calma;
- permanência;
- controle;
- caminhos simples.
[Voltar](/)
Crie pasta de posts:
mkdir -p content/posts
nano content/posts/primeiro-post.md
Conteúdo:
# Primeiro post
Este é o primeiro post do Soledade.
Um blog não precisa começar grande. Precisa apenas começar.
A forma importa: quando a ferramenta é simples, o texto fica mais honesto.
11. Gerador estático
Crie:
nano soledade/build.py
Conteúdo:
#!/usr/bin/env python3
from __future__ import annotations
import html
import shutil
from pathlib import Path
import markdown
from config import CONTENT_DIR, PUBLIC_DIR, TEMPLATE_FILE, SITE_NAME, SITE_DESCRIPTION
def extract_title(md_text: str, fallback: str) -> str:
for line in md_text.splitlines():
if line.startswith("# "):
return line[2:].strip()
return fallback
def render_template(title: str, content_html: str) -> str:
template = TEMPLATE_FILE.read_text(encoding="utf-8")
return (
template
.replace("{{ title }}", html.escape(title))
.replace("{{ site_name }}", html.escape(SITE_NAME))
.replace("{{ site_description }}", html.escape(SITE_DESCRIPTION))
.replace("{{ description }}", html.escape(SITE_DESCRIPTION))
.replace("{{ content }}", content_html)
)
def md_to_html(md_text: str) -> str:
return markdown.markdown(
md_text,
extensions=["extra", "toc", "sane_lists"],
output_format="html5",
)
def output_path_for(source: Path) -> Path:
relative = source.relative_to(CONTENT_DIR)
if relative.name == "index.md":
return PUBLIC_DIR / relative.with_suffix(".html")
if relative.suffix == ".md":
return PUBLIC_DIR / relative.with_suffix("") / "index.html"
return PUBLIC_DIR / relative
def build_page(source: Path) -> None:
md_text = source.read_text(encoding="utf-8")
title = extract_title(md_text, source.stem.replace("-", " ").title())
content_html = md_to_html(md_text)
html_text = render_template(title, content_html)
target = output_path_for(source)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html_text, encoding="utf-8")
print(f"built {source} -> {target}")
def build_posts_index() -> None:
posts_dir = CONTENT_DIR / "posts"
if not posts_dir.exists():
return
links = []
for post in sorted(posts_dir.glob("*.md"), reverse=True):
text = post.read_text(encoding="utf-8")
title = extract_title(text, post.stem.replace("-", " ").title())
url = f"/posts/{post.stem}/"
links.append(f"- [{title}]({url})")
md_text = "# Posts\n\n" + "\n".join(links) + "\n"
html_text = render_template("Posts", md_to_html(md_text))
target = PUBLIC_DIR / "posts" / "index.html"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html_text, encoding="utf-8")
print(f"built posts index -> {target}")
def copy_static_files() -> None:
static_dir = CONTENT_DIR / "static"
if static_dir.exists():
target = PUBLIC_DIR / "static"
if target.exists():
shutil.rmtree(target)
shutil.copytree(static_dir, target)
def main() -> None:
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
for source in CONTENT_DIR.rglob("*.md"):
build_page(source)
build_posts_index()
copy_static_files()
if __name__ == "__main__":
main()
Torne executável:
chmod +x soledade/build.py
Teste:
cd /opt/soledade/soledade
../venv/bin/python build.py
find /opt/soledade/public -type f
12. Servidor web com nginx
Saia do usuário soledade:
exit
Crie configuração nginx:
sudo nano /etc/nginx/sites-available/soledade
Conteúdo:
server {
listen 80;
listen [::]:80;
server_name blog.seudominio.com;
root /opt/soledade/public;
index index.html;
location / {
try_files $uri $uri/ =404;
}
access_log /var/log/nginx/soledade.access.log;
error_log /var/log/nginx/soledade.error.log;
}
Ative:
sudo ln -s /etc/nginx/sites-available/soledade /etc/nginx/sites-enabled/soledade
sudo nginx -t
sudo systemctl reload nginx
Abra:
http://blog.seudominio.com/
13. HTTPS com Certbot
Instale:
sudo apt install -y certbot python3-certbot-nginx
Execute:
sudo certbot --nginx -d blog.seudominio.com
Teste renovação:
sudo certbot renew --dry-run
14. Alternativa com Caddy
Se preferir Caddy em vez de nginx, a configuração é bem menor.
Instale conforme documentação oficial:
Caddyfile:
blog.seudominio.com {
root * /opt/soledade/public
file_server
}
Caddy é excelente porque faz HTTPS automático.
15. Servidor de leitura textual: porta 1900
Agora vamos criar o equivalente minimalista do Nex.
Crie:
sudo -u soledade -H bash
cd /opt/soledade
nano soledade/nex_server.py
Conteúdo:
#!/usr/bin/env python3
from __future__ import annotations
import socketserver
from pathlib import Path
from config import CONTENT_DIR, NEX_HOST, NEX_PORT
WELCOME = """Soledade textual server
Try:
index.md
about.md
posts/primeiro-post.md
"""
def safe_path(raw: str) -> Path | None:
raw = raw.strip().lstrip("/")
if not raw:
raw = "index.md"
candidate = (CONTENT_DIR / raw).resolve()
root = CONTENT_DIR.resolve()
try:
candidate.relative_to(root)
except ValueError:
return None
return candidate
class NexHandler(socketserver.BaseRequestHandler):
def handle(self) -> None:
try:
data = self.request.recv(4096)
path_text = data.decode("utf-8", errors="replace").strip()
if not path_text:
self.request.sendall(WELCOME.encode("utf-8"))
return
path = safe_path(path_text)
if path is None:
self.request.sendall(b"403 forbidden\n")
return
if path.is_dir():
index = path / "index.md"
if index.exists():
path = index
else:
entries = sorted(p.name + ("/" if p.is_dir() else "") for p in path.iterdir())
self.request.sendall(("\n".join(entries) + "\n").encode("utf-8"))
return
if not path.exists() or not path.is_file():
self.request.sendall(b"404 not found\n")
return
self.request.sendall(path.read_bytes())
except Exception as exc:
self.request.sendall(f"500 error: {exc}\n".encode("utf-8", errors="replace"))
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True
daemon_threads = True
def main() -> None:
with ThreadingTCPServer((NEX_HOST, NEX_PORT), NexHandler) as server:
print(f"Soledade Nex-like server listening on {NEX_HOST}:{NEX_PORT}")
server.serve_forever()
if __name__ == "__main__":
main()
Teste localmente:
cd /opt/soledade/soledade
../venv/bin/python nex_server.py
Em outro terminal:
printf "index.md\n" | nc localhost 1900
Se estiver no Windows:
"index.md" | ncat SEU_DOMINIO 1900
16. Servidor de postagem: porta 1915
Agora vem o equivalente do NPS.
Crie:
nano /opt/soledade/soledade/post_server.py
Conteúdo:
#!/usr/bin/env python3
from __future__ import annotations
import datetime as dt
import re
import socketserver
import subprocess
from config import BASE_DIR, CONTENT_DIR, TOKEN_FILE, LOG_DIR, POST_HOST, POST_PORT, MAX_POST_BYTES
VALID_PATH = re.compile(r"^[a-zA-Z0-9_./-]+$")
HELP = """Soledade Posting Service
To publish:
posts/meu-post.md
TOKEN
# Meu post
Conteudo
.
Special commands:
CREATE_DIR
DELETE
"""
def load_token() -> str:
return TOKEN_FILE.read_text(encoding="utf-8").strip()
def log(message: str) -> None:
LOG_DIR.mkdir(parents=True, exist_ok=True)
now = dt.datetime.utcnow().isoformat(timespec="seconds") + "Z"
with (LOG_DIR / "post_server.log").open("a", encoding="utf-8") as f:
f.write(f"[{now}] {message}\n")
def safe_content_path(raw_path: str):
raw_path = raw_path.strip().lstrip("/")
if not raw_path or ".." in raw_path or not VALID_PATH.match(raw_path):
return None
candidate = (CONTENT_DIR / raw_path).resolve()
root = CONTENT_DIR.resolve()
try:
candidate.relative_to(root)
except ValueError:
return None
return candidate
def rebuild_site() -> tuple[bool, str]:
cmd = [str(BASE_DIR / "venv" / "bin" / "python"), str(BASE_DIR / "soledade" / "build.py")]
result = subprocess.run(cmd, cwd=str(BASE_DIR / "soledade"), capture_output=True, text=True, timeout=30)
output = (result.stdout + result.stderr).strip()
return result.returncode == 0, output
class PostHandler(socketserver.BaseRequestHandler):
def handle(self) -> None:
try:
self.request.settimeout(20)
data = bytearray()
while True:
chunk = self.request.recv(4096)
if not chunk:
break
data.extend(chunk)
if len(data) > MAX_POST_BYTES:
self.request.sendall(b"413 payload too large\n")
return
if b"\n.\n" in data or data.endswith(b"\n.\r\n"):
break
text = data.decode("utf-8", errors="replace")
lines = text.replace("\r\n", "\n").split("\n")
if len(lines) < 3:
self.request.sendall(HELP.encode("utf-8"))
return
target_raw = lines[0].strip()
token = lines[1].strip()
content_lines = []
for line in lines[2:]:
if line == ".":
break
content_lines.append(line)
if token != load_token():
log(f"auth failed for {target_raw} from {self.client_address[0]}")
self.request.sendall(b"403 invalid token\n")
return
target = safe_content_path(target_raw)
if target is None:
self.request.sendall(b"400 invalid path\n")
return
body = "\n".join(content_lines).rstrip() + "\n"
if body.strip() == "CREATE_DIR":
target.mkdir(parents=True, exist_ok=True)
log(f"created dir {target}")
self.request.sendall(b"200 directory created\n")
return
if body.strip() == "DELETE":
if target.is_dir():
self.request.sendall(b"400 refusing to delete directory\n")
return
if target.exists():
target.unlink()
ok, output = rebuild_site()
log(f"deleted {target}; rebuild={ok}")
self.request.sendall(f"200 deleted\n{output}\n".encode("utf-8", errors="replace"))
return
self.request.sendall(b"404 file not found\n")
return
if not target.name.endswith(".md"):
self.request.sendall(b"400 only .md files are allowed\n")
return
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
ok, output = rebuild_site()
if ok:
log(f"published {target}")
self.request.sendall(f"200 published\n{output}\n".encode("utf-8", errors="replace"))
else:
log(f"rebuild failed for {target}: {output}")
self.request.sendall(f"500 rebuild failed\n{output}\n".encode("utf-8", errors="replace"))
except Exception as exc:
log(f"error: {exc}")
self.request.sendall(f"500 error: {exc}\n".encode("utf-8", errors="replace"))
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
allow_reuse_address = True
daemon_threads = True
def main() -> None:
with ThreadingTCPServer((POST_HOST, POST_PORT), PostHandler) as server:
print(f"Soledade Posting Service listening on {POST_HOST}:{POST_PORT}")
server.serve_forever()
if __name__ == "__main__":
main()
Teste:
cd /opt/soledade/soledade
../venv/bin/python post_server.py
Em outro terminal:
TOKEN=$(cat /opt/soledade/secrets/token.txt)
cat <<EOF | nc localhost 1915
posts/teste.md
$TOKEN
# Teste
Publicado via terminal.
.
EOF
17. systemd para manter os serviços vivos
Saia do usuário soledade:
exit
Crie serviço do leitor textual:
sudo nano /etc/systemd/system/soledade-nex.service
Conteúdo:
[Unit]
Description=Soledade Nex-like Read Server
After=network.target
[Service]
Type=simple
User=soledade
Group=soledade
WorkingDirectory=/opt/soledade/soledade
ExecStart=/opt/soledade/venv/bin/python /opt/soledade/soledade/nex_server.py
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/opt/soledade
[Install]
WantedBy=multi-user.target
Crie serviço do publicador:
sudo nano /etc/systemd/system/soledade-post.service
Conteúdo:
[Unit]
Description=Soledade Posting Service
After=network.target
[Service]
Type=simple
User=soledade
Group=soledade
WorkingDirectory=/opt/soledade/soledade
ExecStart=/opt/soledade/venv/bin/python /opt/soledade/soledade/post_server.py
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ReadWritePaths=/opt/soledade
[Install]
WantedBy=multi-user.target
Ative:
sudo systemctl daemon-reload
sudo systemctl enable --now soledade-nex.service
sudo systemctl enable --now soledade-post.service
Verifique:
sudo systemctl status soledade-nex.service
sudo systemctl status soledade-post.service
Logs:
journalctl -u soledade-nex.service -f
journalctl -u soledade-post.service -f
18. Firewall
Abra SSH, HTTP, HTTPS, Nex e postagem:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 1900/tcp
sudo ufw allow 1915/tcp
sudo ufw enable
sudo ufw status verbose
Se quiser ser mais seguro, deixe a porta 1915 aberta apenas para seu IP:
sudo ufw delete allow 1915/tcp
sudo ufw allow from SEU_IP_PUBLICO to any port 1915 proto tcp
Essa é a escolha mais sensata se o blog for só seu.
19. Publicando do Windows com Ncat
Você já tem Ncat instalado se instalou Nmap:
ncat --version
Para publicar:
ncat blog.seudominio.com 1915
Digite:
posts/minha-noite.md
SEU_TOKEN
# Minha noite
Texto publicado pelo terminal no Windows.
.
Para ler:
"posts/minha-noite.md" | ncat blog.seudominio.com 1900
Se quiser guardar o token em arquivo local no Windows:
Set-Content -Path "$HOME\.soledade-token" -Value "SEU_TOKEN"
Publicação usando arquivo:
$token = Get-Content "$HOME\.soledade-token"
@"
posts/post-via-powershell.md
$token
# Post via PowerShell
Funciona.
.
"@ | ncat blog.seudominio.com 1915
20. Cliente simples de publicação em PowerShell
Crie soledade-publish.ps1:
param(
[Parameter(Mandatory=$true)]
[string]$Path,
[Parameter(Mandatory=$true)]
[string]$File,
[string]$HostName = "blog.seudominio.com",
[int]$Port = 1915,
[string]$TokenFile = "$HOME\.soledade-token"
)
if (!(Test-Path $File)) {
Write-Error "Arquivo nao encontrado: $File"
exit 1
}
if (!(Test-Path $TokenFile)) {
Write-Error "Token nao encontrado: $TokenFile"
exit 1
}
$token = Get-Content $TokenFile -Raw
$token = $token.Trim()
$content = Get-Content $File -Raw
$payload = @"
$Path
$token
$content
.
"@
$payload | ncat $HostName $Port
Uso:
.\soledade-publish.ps1 -Path "posts/meu-post.md" -File ".\meu-post.md"
21. Cliente simples de publicação em Bash
Crie soledade-publish:
#!/usr/bin/env bash
set -euo pipefail
HOST="${SOLED_HOST:-blog.seudominio.com}"
PORT="${SOLED_PORT:-1915}"
TOKEN_FILE="${SOLED_TOKEN_FILE:-$HOME/.soledade-token}"
if [ "$#" -ne 2 ]; then
echo "uso: soledade-publish caminho/remoto.md arquivo-local.md" >&2
exit 1
fi
REMOTE_PATH="$1"
LOCAL_FILE="$2"
TOKEN="$(cat "$TOKEN_FILE")"
{
printf '%s\n' "$REMOTE_PATH"
printf '%s\n' "$TOKEN"
cat "$LOCAL_FILE"
printf '\n.\n'
} | nc "$HOST" "$PORT"
Instale:
chmod +x soledade-publish
sudo mv soledade-publish /usr/local/bin/
Uso:
soledade-publish posts/noite.md noite.md
22. Melhorando o conteúdo do blog
Modelo de post
# Título do post
Publicado em: 2026-05-31
Primeiro parágrafo direto. Sem enrolação.
## Ideia
Explique a ideia central.
## Desenvolvimento
Desenvolva sem transformar o texto em tese.
## Fechamento
Termine com algo que mereça ficar.
Modelo de página inicial
# Soledade
Uma casa textual em um canto silencioso da rede.
## Entradas
- [Sobre](/about/)
- [Posts](/posts/)
- [Notas](/notes/)
- [Links](/links/)
## Sinal atual
Construindo sistemas pequenos contra a obesidade da web.
Modelo de links
# Links
## Internet textual
- [Nightfall City](https://nightfall.city/)
- [Gemini Protocol](https://geminiprotocol.net/)
- [Project Gemini](https://gemini.circumlunar.space/)
## Ferramentas
- [Python](https://www.python.org/)
- [nginx](https://nginx.org/)
- [Caddy](https://caddyserver.com/)
- [Zola](https://www.getzola.org/)
23. Segurança: onde você pode fazer besteira
Aqui vai a parte sem romantismo.
Nunca aceite caminho livre sem validação
Errado:
path = CONTENT_DIR / user_input
Isso permite ataques tipo:
../../etc/passwd
Certo:
- remover
/inicial; - bloquear
..; - resolver caminho absoluto;
- conferir se ele continua dentro de
CONTENT_DIR.
O código do tutorial já faz isso.
Não use senha fraca
Use token grande:
openssl rand -hex 32
Não exponha o token no Git
Nunca suba:
secrets/token.txt
Use .gitignore:
secrets/
logs/
venv/
public/
Limite tamanho de postagem
O tutorial usa:
MAX_POST_BYTES = 128 * 1024
Isso evita abuso básico.
Não permita HTML irrestrito se outras pessoas forem postar
Para blog pessoal, ok. Para multiusuário, você precisa sanitizar HTML.
Ferramentas:
- Bleach: https://bleach.readthedocs.io/
Não faça multiusuário agora
Um blog pessoal é fácil. Uma cidade pública é outro jogo.
Multiusuário exige:
- cadastro;
- tokens por usuário;
- quotas;
- moderação;
- logs;
- recuperação de chave;
- proteção contra spam;
- backup individual;
- política de abuso.
Não comece por aí.
24. Backups
Backup simples com tar:
sudo tar -czf /root/soledade-backup-$(date +%F).tar.gz /opt/soledade/content /opt/soledade/secrets
Backup com rsync para outro servidor:
rsync -avz /opt/soledade/content usuario@backup:/backups/soledade/content
Cron diário:
sudo crontab -e
Adicione:
30 3 * * * tar -czf /root/soledade-backup-$(date +\%F).tar.gz /opt/soledade/content /opt/soledade/secrets
25. Logs úteis
Ver serviços:
systemctl status soledade-nex
systemctl status soledade-post
Ver logs em tempo real:
journalctl -u soledade-nex -f
journalctl -u soledade-post -f
Log próprio do publicador:
cat /opt/soledade/logs/post_server.log
Logs nginx:
sudo tail -f /var/log/nginx/soledade.access.log
sudo tail -f /var/log/nginx/soledade.error.log
26. Ideias para evolução
RSS
Gerar /feed.xml para leitores RSS.
Referência:
- RSS Advisory Board: https://www.rssboard.org/rss-specification
Sitemap
Gerar /sitemap.xml.
Referência:
- Sitemaps: https://www.sitemaps.org/
Modo Gemini
Criar uma versão .gmi dos posts.
Links:
- Gemini Protocol: https://geminiprotocol.net/
- Gemini Project: https://gemini.circumlunar.space/
Busca local
Gerar índice JSON:
/public/search.json
E uma busca simples em JavaScript.
Tags
Usar metadados no topo dos posts:
---
title: Meu post
date: 2026-05-31
tags: [internet, texto, sistemas]
---
Para isso, use:
- python-frontmatter: https://github.com/eyeseast/python-frontmatter
Editor TUI
Você poderia criar um cliente de terminal com:
- Textual: https://textual.textualize.io/
- Rich: https://rich.readthedocs.io/
Mas não faça isso no começo. Primeiro deixe o sistema simples funcionar.
27. Versão mínima extrema
Se você quiser algo ainda mais bruto, pode ignorar Markdown e servir .txt puro.
Estrutura:
/opt/soledade/content/index.txt
/opt/soledade/content/posts/primeiro.txt
nginx:
server {
listen 80;
server_name blog.seudominio.com;
root /opt/soledade/content;
autoindex on;
}
Isso é funcional, mas feio para navegador comum. O modelo com Markdown é melhor.
28. Checklist final
Servidor
- VPS criada
- Debian/Ubuntu instalado
- domínio apontando para o IP
- usuário
soledadecriado - diretórios criados
- virtualenv criado
- Python-Markdown instalado
Site
-
config.pyajustado -
base.htmlcriado -
index.mdcriado -
about.mdcriado -
build.pyfuncionando - nginx ou Caddy servindo
/opt/soledade/public - HTTPS funcionando
Protocolos
-
nex_server.pyrodando -
post_server.pyrodando - systemd ativado
- porta 1900 aberta
- porta 1915 aberta ou restrita ao seu IP
Segurança
- token forte criado
- token fora do Git
- UFW ativo
- backup configurado
- logs verificados
29. Comandos rápidos de administração
Reconstruir site:
sudo -u soledade bash -lc 'cd /opt/soledade/soledade && ../venv/bin/python build.py'
Reiniciar serviços:
sudo systemctl restart soledade-nex
sudo systemctl restart soledade-post
sudo systemctl reload nginx
Ver portas:
sudo ss -tulpn | grep -E ':(80|443|1900|1915)'
Testar leitura:
printf "index.md\n" | nc localhost 1900
Testar publicação:
TOKEN=$(sudo cat /opt/soledade/secrets/token.txt)
cat <<EOF | nc localhost 1915
posts/teste-final.md
$TOKEN
# Teste final
Se isto apareceu, o Soledade vive.
.
EOF
30. Minha recomendação final
Construa o Soledade em fases:
Fase 1
Blog estático com Markdown + nginx.
Fase 2
Servidor de leitura textual na porta 1900.
Fase 3
Servidor de postagem na porta 1915.
Fase 4
Cliente PowerShell/Bash para publicar com conforto.
Fase 5
RSS, tags, busca, tema visual e melhorias.
Não tente começar pela versão multiusuário. Isso é a armadilha. Primeiro faça um blog pessoal excelente, pequeno e vivo.
A filosofia correta aqui é:
pequeno > grande
texto > interface inchada
arquivo > banco desnecessário
terminal > painel pesado
clareza > framework da moda
O Soledade deve ser uma ferramenta, não uma plataforma. Plataforma demais vira cemitério de manutenção.
31. Referências
- Nightfall City: https://nightfall.city/
- Nightfall Citizenship: https://nightfall.city/citizenship/
- Nightfall Citizenship Manual: https://nightfall.city/citizenship/manual.txt
- Nex Specification: https://nightfall.city/nex/info/specification.txt
- NPS Specification: https://nightfall.city/nps/info/specification.txt
- Python: https://www.python.org/
- Python socketserver: https://docs.python.org/3/library/socketserver.html
- Python http.server: https://docs.python.org/3/library/http.server.html
- Python-Markdown: https://python-markdown.github.io/
- Markdown PyPI: https://pypi.org/project/Markdown/
- Mistune: https://mistune.lepture.com/
- nginx Docs: https://nginx.org/en/docs/
- nginx Reverse Proxy Docs: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
- Caddy: https://caddyserver.com/
- Caddy Automatic HTTPS: https://caddy.guide/docs/automatic-https
- Certbot: https://certbot.eff.org/
- Debian Certbot manpage: https://manpages.debian.org/bookworm/certbot/certbot.7.en.html
- UFW: https://help.ubuntu.com/community/UFW
- Fail2Ban: https://github.com/fail2ban/fail2ban
- Git: https://git-scm.com/
- rsync: https://rsync.samba.org/
- Ncat: https://nmap.org/ncat/
- Hugo: https://gohugo.io/
- Zola: https://www.getzola.org/
- Eleventy: https://www.11ty.dev/
- Jekyll: https://jekyllrb.com/
- Gemini Protocol: https://geminiprotocol.net/
- Sitemaps: https://www.sitemaps.org/
- RSS Specification: https://www.rssboard.org/rss-specification
- Rich: https://rich.readthedocs.io/
- Textual: https://textual.textualize.io/
Related documents
- 001
- 002
- 003
- 004
research · MD
100 jogos cozy para quem ama Stardew Valley - 005