Integrate it in your site
This manual is written so that another person —or an AI assistant— can integrate reply2social into any site without knowing this code.
Every step comes with how to verify it. That is not fussiness: in the real
deployment of this very system, the process ended in ”✓ OK” three times in a
row with something broken — an image that was not built, an npm ci that was
failing, a screen that never asked for its data. The site answered 200 in all
three cases.
What you are going to integrate
They are two separable pieces and it is worth being clear about that from the start:
| Piece | What it is | Mandatory? |
|---|---|---|
| The service | A Rust binary + Postgres. Fetches material, archives it, publishes it. | Yes |
| The panel | Two npm packages with the eight screens. | No: you can talk to the service over HTTP from your own front end |
This manual covers both. If you only want the service, stop at step 5.
Step 0 — Requirements and the code
The first thing is having the code. The repo is public:
git clone https://gitlab.com/pineiden/reply2fb.git reply2socialThe destination name matters: the compose.yml in step 3 points at
./reply2social/backend. The details —downloading it without git, pinning a
version, what is in each directory and what the AGPL requires of you— are in
Download the code.
On the machine where the service will run:
- Postgres 14+ (tested on 16).
- Podman or Docker, with compose.
- Node 22+, only if you are going to build the panel.
- Outbound internet access to the networks you are going to use.
Verify
podman --version || docker --versionpsql --versionnode --version
# And that the clone is complete:ls reply2social/backend/Cargo.toml reply2social/ui/core/package.jsonThe three version commands must answer, and the two files must exist. If psql
is missing, you can use the Postgres from the compose file and skip the local
installation.
Step 1 — The contract: what your site has to offer
This is the step that decides whether the integration is possible at all. The service has no users of its own: it asks your site who each person is.
1.1 Always mandatory: validating identity
Your site exposes one endpoint that the service calls on every request.
You choose the path: the service calls whatever you put in
IDENTIDAD_URL_VALIDAR. It can be /api/reply2fb/validar, /auth/verify or
/rpc/who-is-this; the first one is used here as an example.
Request that reaches your site:
POST <the path you chose>X-API-Key: <the shared secret>Content-Type: application/json
{ "token": "<whatever the browser sent in Authorization: Bearer …>" }Expected response, 200:
{ "id": "uuid-or-whatever-you-use", "roles": ["admin"] }Response when the token is not valid, 401: any body.
Three rules your implementation has to follow, and all three come from a real incident:
- Compare the
X-API-Keyin constant time. A normal comparison leaks the secret character by character given enough attempts. - Re-read the user from YOUR database on every call. Do not trust what the token says. That is what makes suspending someone take effect immediately instead of waiting for expiry.
- If you cannot decide, return 5xx, not 401. A 401 asserts “you are nobody”; if your database is down, the truth is “I could not ask”. The service translates that into a 503 and the panel says “could not be queried” instead of sending the user to log in again, which would fix nothing.
A minimal example, in three languages:
// Express / Nodeapp.post('/api/reply2fb/validar', async (req, res) => { const sent = Buffer.from(req.get('X-API-Key') ?? ''); const ours = Buffer.from(process.env.REPLY2FB_IDENTIDAD_SECRETO); if (sent.length !== ours.length || !crypto.timingSafeEqual(sent, ours)) { return res.sendStatus(401); } try { // Re-read from the database: the token says who they WERE, the DB who they ARE. const u = await userFromToken(req.body.token); if (!u || u.status !== 'active') return res.sendStatus(401); res.json({ id: u.id, roles: [u.role] }); } catch (e) { // I could not ask ≠ you are nobody. res.sendStatus(503); }});# FastAPI@app.post("/api/reply2fb/validar")async def validar(body: dict, x_api_key: str = Header(None)): if not hmac.compare_digest(x_api_key or "", SECRET): raise HTTPException(401) try: u = await user_from_token(body["token"]) except Exception: raise HTTPException(503) # I could not ask if not u or u.status != "active": raise HTTPException(401) # you are nobody return {"id": str(u.id), "roles": [u.role]}// LaravelRoute::post('/api/reply2fb/validar', function (Request $r) { if (!hash_equals(config('r2f.secret'), $r->header('X-API-Key', ''))) { abort(401); } try { $u = userFromToken($r->input('token')); } catch (\Throwable $e) { abort(503); } if (!$u || $u->status !== 'active') abort(401); return ['id' => (string) $u->id, 'roles' => [$u->role]];});1.2 Only if you are going to publish INTO your own site
The web platform —“your own site” as both source and destination— is the only
part of reply2social that expects paths of a fixed shape. Everything else
(Instagram, Mastodon, Telegram, RSS) talks to third-party APIs and asks nothing
of you.
Path A — implement the routes. Five endpoints with these exact shapes:
GET {api_url}/api/perfil/me → 200 with the token's userGET {api_url}/api/admin/usuarios → 200 [{id, username, estado, rol}]
POST {api_url}/api/postsX-API-Key: <IDENTIDAD_SECRETO>{ "cuerpo": "…", "fuente_url": "https://…", "fuente_nombre": "Instagram" } → 2xx { "id": "…" }
POST {api_url}/api/posts/{id}/mediaX-API-Key: <IDENTIDAD_SECRETO>multipart/form-data, field «file» → 2xx
GET {api_url}/api/posts → 200 [ … ] (to read FROM your site)They are five thin routes; in most frameworks it is one controller translating
into your model. fuente_url and fuente_nombre are the attribution: if
your site displays them in a field of its own, do not also paste them into the
body.
Path B — an adapter in front. A twenty-line proxy that receives those routes and calls yours. Useful when you do not want to touch your API (WordPress, Ghost, a CMS you do not control).
Path C — do not use web at all. This is the most common case: if you only
want Instagram → Mastodon, or RSS → Telegram, skip this whole section. You
need to implement none of it and the rest of the manual applies just the same.
Verify step 1
There is no service yet, so this is tested by hand against your site:
# With the right secret and a valid token → 200 with id and rolescurl -s -X POST https://YOUR-SITE/api/reply2fb/validar \ -H "X-API-Key: $SECRET" -H 'Content-Type: application/json' \ -d '{"token":"A_VALID_TOKEN"}' -w '\n%{http_code}\n'
# With the WRONG secret → 401curl -s -o /dev/null -X POST https://YOUR-SITE/api/reply2fb/validar \ -H "X-API-Key: wrong" -H 'Content-Type: application/json' \ -d '{"token":"x"}' -w '%{http_code}\n'The first must return 200 and a JSON with id and roles. The second, 401.
If the second returns 200, stop here: anybody could pass themselves off as
an administrator.
Step 2 — The environment variables
They all go into a .env next to the compose file. The ones marked
mandatory make the service fail to start if they are missing, on
purpose: a half-configured service that starts is worse than one that does not.
| Variable | Mandatory | What it is |
|---|---|---|
DATABASE_URL | Yes | postgres://user:password@host:5432/reply2fb |
REPLY2FB_CIFRADO_KEY | Yes | 32 bytes in base64. Encrypts the network tokens |
IDENTIDAD_URL_VALIDAR | Yes | The URL from step 1.1, as the service sees it |
IDENTIDAD_SECRETO | Yes | The secret shared with your site |
IDENTIDAD_MAPA_ROLES | No | admin=administrar,editor=operar,lector=ver |
REPLY2FB_EMISION_ACTIVA | No | 1 turns publishing on. Without it, it only archives |
REPLY2FB_UID_SERVICIO | web only | Under which user it publishes to your site |
PORT | No | 8080 by default |
Generating the secret and the key:
# The encryption key: EXACTLY 32 bytes.openssl rand -base64 32
# The secret shared with your site.openssl rand -base64 36 | tr -d '/+=' | head -c 48REPLY2FB_CIFRADO_KEY is not rotated lightly. Every network token was
encrypted with it: if you change it, none of them can be opened and they have to
be loaded again one by one. Keep it wherever you keep production keys, and take
a backup (cauce exportar --respaldo) before touching it.
Verify step 2
# The key must decode to exactly 32 bytesecho -n "$REPLY2FB_CIFRADO_KEY" | base64 -d | wc -c # → 32Any other number and the service will fail at startup saying so.
Step 3 — Bringing the service up
services: reply2fb-db: image: docker.io/postgres:16-alpine environment: POSTGRES_DB: reply2fb POSTGRES_USER: reply2fb POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD missing} volumes: ['reply2fb-datos:/var/lib/postgresql/data'] healthcheck: test: ['CMD-SHELL', 'pg_isready -U reply2fb'] interval: 10s
reply2fb: build: # The clone directory from step 0. If you cloned without naming the # destination, this is `./reply2fb/backend`. context: ./reply2social/backend environment: DATABASE_URL: postgres://reply2fb:${DB_PASSWORD}@reply2fb-db:5432/reply2fb REPLY2FB_CIFRADO_KEY: ${REPLY2FB_CIFRADO_KEY:?missing} IDENTIDAD_URL_VALIDAR: ${IDENTIDAD_URL_VALIDAR:?missing} IDENTIDAD_SECRETO: ${IDENTIDAD_SECRETO:?missing} IDENTIDAD_MAPA_ROLES: admin=administrar,editor=operar,lector=ver REPLY2FB_EMISION_ACTIVA: '0' depends_on: reply2fb-db: { condition: service_healthy } ports: ['127.0.0.1:8080:8080']
volumes: reply2fb-datos:podman-compose up -d --buildThe migrations run by themselves at startup.
Start with REPLY2FB_EMISION_ACTIVA=0. With that, the system archives and
does not publish. You will be able to configure everything, watch what comes in,
and only turn publishing on when you are sure. The other way round —turn it on
first and configure afterwards— the first configuration mistake goes out
published on somebody else’s account.
Verify step 3
# 1. The container is up and NOT restartingpodman ps --format '{{.Names}} {{.Status}}' | grep reply2fb
# 2. It answers, and rejects whoever does not identify themselvescurl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/api/emisionesThe second must give 401.
- If it gives 503: the service cannot talk to your site. Check
IDENTIDAD_URL_VALIDAR— from inside the container,localhostis the container, not your machine. - If it gives 000 or does not connect: the service did not come up.
podman logs reply2fb. - If it gives 200: something is very wrong — it should not let anyone in without a token.
Step 4 — The proxy
The browser has to be able to reach the service with your site’s session.
You choose the prefix —/reply2fb/ is used here— and the only thing that
matters is that the panel’s transport uses the same one. The service neither
knows nor cares under which path you published it.
location /reply2fb/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header Authorization $http_authorization;
# Videos take time: without this a large backfill is cut off at 60 s. proxy_read_timeout 300s; client_max_body_size 512M;}A third party’s account token never passes through your backend. The panel
hits /reply2fb/* directly and nginx forwards it to the service: it does not go
through your application, nor your database, nor your logs. If you route it
through your backend “to keep things simple”, you take on the responsibility of
holding other people’s credentials.
Verify step 4
curl -s -o /dev/null -w '%{http_code}\n' https://YOUR-SITE/reply2fb/api/emisionescurl -s -o /dev/null -w '%{http_code}\n' \ -H 'Authorization: Bearer fake-token' https://YOUR-SITE/reply2fb/api/emisionesBoth must give 401. A 502 means nginx cannot reach the service; a 404, that the prefix does not match.
Step 5 — The first account, still without a panel
Before mounting the interface, check that the service does its job. All of this can be done from the CLI:
Replace reply2fb with whatever you named the container.
# See which platforms exist and which ones have a clientpodman exec reply2fb reply2fb cuentas list
# Sign up an RSS source: it is the easiest to test, it needs no tokenpodman exec reply2fb reply2fb cuentas add \ --plataforma rss --rol lectura \ --handle prensa --id-remoto https://example.org/feed.xmlSigning up verifies before writing: if the feed does not answer or is not a feed, it does not create the account and says why. That is on purpose — a source that cannot be read would poll every six hours bringing back nothing.
Verify step 5
podman exec reply2fb reply2fb poller --una-vezpodman exec reply2fb reply2fb cuentas listIf items came in, the service works. What is missing is the interface.
Step 6 — Mounting the panel
6.1 Getting the packages
They live in the same repo you cloned in step 0 —how to get
it— under ui/. They are packaged with:
# Inside the clonenode ui/empaquetar.mjs --destino /path/to/your-site/vendorThat runs both packages’ tests, compiles them, and leaves two .tgz files with
the commit in the name, plus a PROCEDENCIA.json saying which version they came
from.
In your site:
{ "dependencies": { "@reply2social/core": "file:./vendor/reply2social-core-0.1.0-abc12345.tgz", "@reply2social/svelte": "file:./vendor/reply2social-svelte-0.1.0-abc12345.tgz" }}The name carries the sha on purpose. With a fixed name, npm serves the
tarball it has in cache even if the contents changed: your site compiles green
with the previous code and nobody finds out. If you update the packages, the
name changes and so does the package.json.
6.2 The transport
The core does not know how your site authenticates: you hand it over. A
Transporte is any function with fetch’s signature:
import { crearCliente } from '@reply2social/core';
// The path goes WITHOUT the prefix: the transport adds it.const transporte = (ruta: string, opts: RequestInit = {}) => fetch(`/reply2fb${ruta}`, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${yourToken}`, ...(opts.headers ?? {}), }, });
const cliente = crearCliente(transporte);With session cookies instead of a token: credentials: 'include' and no
Authorization.
6.3 Mounting the screens
<script lang="ts"> import { crearCliente, crearVistaPorAprobar } from '@reply2social/core'; import { PorAprobar, Confirmar, crearConfirmador } from '@reply2social/svelte'; import '@reply2social/svelte/estilos.css';
const confirmador = crearConfirmador(); const cliente = crearCliente(transporte);
// Once only: recreating it on every render loses the selection mid-batch. const vista = crearVistaPorAprobar(cliente, confirmador.confirmar);</script>
<!-- The dialog is mounted ONCE, at the very top --><Confirmar {confirmador} /><PorAprobar {vista} />The eight screens all follow the same pattern:
| Component | Its view |
|---|---|
PorAprobar | crearVistaPorAprobar(cliente, confirmar) |
Historial | crearVistaHistorial(cliente, confirmar) |
Archivo | crearVistaArchivo(cliente, confirmar) |
Fuentes | crearVistaFuentes(cliente, confirmar) |
Alta | crearVistaAlta(cliente, confirmar) |
Cuentas | crearVistaCuentas(cliente, confirmar) |
Programacion | crearVistaProgramacion(cliente, confirmar) |
Rescate | crearVistaRescate(cliente) |
Avisos | crearVistaAvisos(cliente) |
Verify step 6
npm run buildgrep -rl "sin permiso registrado" dist/ | head -1If the second finds nothing, your bundle does NOT contain the panel even though
the build said “Complete”: check that the package.json points at the right
tarball and that npm install actually installed it.
And open the screen. It has to show data or an explained error. If it says “Cargando…” forever, the component is not asking for anything: check that you are passing the view, not the client.
Step 7 — The colours
The package ships its own stylesheet (@reply2social/svelte/estilos.css) with a
dark scheme and an automatic light variant. If your site already has a
palette, do not import the stylesheet: define the same names from yours.
/* On the container where you mount the panel */.my-panel { --r2s-fondo: var(--my-surface); --r2s-tinta: var(--my-text); --r2s-borde: var(--my-control-border); --r2s-ok: var(--my-green); --r2s-fallo-texto: var(--my-readable-red); --r2s-fallo-borde: var(--my-red); --r2s-fallo-fondo: var(--my-red-background); --r2s-atribucion: var(--my-amber); --r2s-duda: var(--my-tinted-grey); --r2s-peligro: var(--my-red);}Two rules when re-theming, and they are not about style:
--r2s-dudacannot be the “disabled” grey. It marks what the panel does NOT know —a regime or a service state that is new to it— and in a muted grey it reads as “ignorable”, which is the opposite. Give it a tint of its own.--r2s-atribucioncan be neither red nor grey. It marks what was published with attribution and without recorded consent: it is not an error —painting it as one teaches people to ignore errors— but it is not neutral either, because whoever approves has to see it. Amber.
What you cannot break even if you want to: the glyphs and the dotted border of “unknown” travel in the package’s markup. That is the half of the meaning that survives any theme, and the reason re-theming is safe.
Verify step 7
Open the Flows screen with a destination that has no permission and check four
states at once: one in green (tested), one in red (no permission), one in
amber (paused) and one in grey with ? (untested). If two of them look the
same, the theme merged two families and they have to be separated.
Measure the contrast of the text against its background: 4.5:1 minimum. In the real deployment, the default green on a dark panel gave 2:1 —illegible— and nobody saw it until it was published.
Step 8 — Turning publishing on
Only now, and in this order:
- Record the permission from the source towards each destination, in the panel, with the name of whoever asserts it. There is no CLI shortcut, and that is on purpose.
- Test the flow without publishing: Flows → “Probar”, then “Ensayo en seco” (dry run).
- Publish ONE item by hand and look at it on the destination network: “Publicar uno”.
- Only then,
REPLY2FB_EMISION_ACTIVA=1.
A flow without recorded consent does not emit, and the panel draws it broken. That is not bureaucratic friction: this system republishes other people’s work, and the permission is a personal, dated assertion by whoever granted it. There is no way to grant it in bulk, not even by importing a file.
Final verification
Run this whole thing. All six must give what is expected; if one fails, the integration is not complete even if everything else works.
SITE=https://your-site
echo "1. no token → $(curl -so /dev/null -w %{http_code} $SITE/reply2fb/api/emisiones) expected 401"echo "2. fake token → $(curl -so /dev/null -w %{http_code} -H 'Authorization: Bearer x' $SITE/reply2fb/api/emisiones) expected 401"echo "3. wrong secret → $(curl -so /dev/null -w %{http_code} -X POST $SITE/api/reply2fb/validar -H 'X-API-Key: wrong' -H 'Content-Type: application/json' -d '{}') expected 401"echo "4. panel in bundle → $(grep -rl 'sin permiso registrado' dist/ | wc -l) expected 1 or more"echo "5. accounts exist → $(podman exec reply2fb reply2fb cuentas list | grep -c .) expected 1 or more"echo "6. no credentials in the response:"curl -s -H "Authorization: Bearer $ADMIN_TOKEN" $SITE/reply2fb/api/cuentas \ | grep -c 'credenciales_cifradas'The sixth must give 0: no endpoint returns the encrypted column.
Frequent errors, and what they actually mean
All of these happened in the real deployment of this system.
| What you see | What it usually is |
|---|---|
| “Cargando…” forever | You passed the component the client instead of the view, or the view was never created. The screen mounts and asks for nothing |
| The panel looks “bare”, without colours | You imported neither estilos.css nor defined the --r2s-*. The colours fall back to reserves designed for another scheme |
| Everything answers 503 | The service cannot reach your validation endpoint. From inside the container, localhost is the container |
| Everything answers 401 with a good token | Your endpoint returns 401 when the database query fails. It has to return 5xx |
| The deploy says OK and you see the old code | The image was not rebuilt, or npm served a cached tarball. Compare the image date, not the health check |
| A flow does not emit and does not say why | The consent is missing. The panel draws it broken; on the CLI, rutas list shows it |
| Things get published without anyone approving | That flow has auto_publica. It is switched on by hand and confirmed |
Why this manual insists so much on verifying.
Integrating this is eight steps and each one has a way of failing silently: a build that does not run, an old tarball, a screen that asks for no data, a 2:1 colour. None of those show up in a health check, and all of them show up in thirty seconds if you know what to look at.
The site answering 200 proves nothing about reply2social.