Reverse proxy that sits in front of GoToSocial and repairs custom emoji objects
  • Go 95.8%
  • Dockerfile 4.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-11 22:05:16 -07:00
.forgejo/workflows ci: build multi-arch and push to Docker Hub plus Forgejo 2026-08-11 21:50:34 -07:00
assets feat: configure via GTS_DOMAIN and serve the placeholder image 2026-08-11 21:50:23 -07:00
.dockerignore ci: build multi-arch and push to Docker Hub plus Forgejo 2026-08-11 21:50:34 -07:00
.gitignore fix: preserve Host header for inbound HTTP signatures 2026-07-03 17:40:23 -07:00
Dockerfile feat: configure via GTS_DOMAIN and serve the placeholder image 2026-08-11 21:50:23 -07:00
go.mod Add gts-emoji-fix-sidecar: repair GoToSocial empty-URL custom emoji 2026-07-03 14:49:45 -07:00
LICENSE docs: rewrite README for public use, add GPL-3 and security policy 2026-08-11 21:50:43 -07:00
main.go feat: configure via GTS_DOMAIN and serve the placeholder image 2026-08-11 21:50:23 -07:00
main_test.go feat: configure via GTS_DOMAIN and serve the placeholder image 2026-08-11 21:50:23 -07:00
README.md README tweak 2026-08-11 22:05:16 -07:00
SECURITY.md docs: rewrite README for public use, add GPL-3 and security policy 2026-08-11 21:50:43 -07:00

gts-emoji-fix-sidecar

A tiny reverse proxy that sits in front of GoToSocial and repairs custom emoji objects that GoToSocial serves with empty url and static_url fields.

GoToSocial emits those empty fields for remote emoji it has not cached yet. Strict clients treat an empty URL as invalid and fail the decode of the entire API response, so one bad emoji blanks a whole timeline. Ice Cubes hits this, and the official Mastodon apps appear to as well. The GoToSocial issue is #4817.

This sidecar fills in the empty URLs on the way out, so clients decode the response normally. No changes to GoToSocial, no database edits, no plugins. Remove it and you are back where you started.

AI Disclosure

This container was written by Claude (Opus 5). I've been using it for ~2 months without issue before I decided to publish it publicly.

How it works

  • Strips inbound Accept-Encoding so GoToSocial returns uncompressed bodies.
  • On JSON responses, byte-replaces the exact adjacent pair "url":"","static_url":"" with a working placeholder URL. Anchoring on both empty fields together makes false matches effectively impossible.
  • Serves that placeholder image itself, at /_emoji-fix/missing.png, so you do not have to host a file anywhere.
  • WebSocket / streaming responses and all non-JSON traffic (media, web UI) pass through untouched.

The whole thing is ~200 lines of Go with no dependencies outside the standard library.

Quick start

Put the sidecar where your reverse proxy currently points at GoToSocial, and point it at GoToSocial. GoToSocial itself stays internal and unchanged.

With Traefik and Docker Compose, that means moving the traefik.* labels off the GoToSocial service and onto the sidecar:

services:
  gotosocial:
    # ... your existing GoToSocial config, unchanged ...
    # REMOVE the traefik.* labels from here; GoToSocial is internal now.
    networks:
      - gotosocial_net

  gts-emoji-fix-sidecar:
    image: docker.io/thefizi/gts-emoji-fix-sidecar:latest   # pin a version tag in production
    restart: unless-stopped
    environment:
      - GTS_DOMAIN=social.example.org      # your instance's public domain
      - BACKEND=http://gotosocial:8080     # service name + port of your GTS container
    networks:
      - gotosocial_net
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=gotosocial_net"
      - "traefik.http.routers.gotosocial.rule=Host(`social.example.org`)"
      - "traefik.http.routers.gotosocial.entrypoints=websecure"
      - "traefik.http.routers.gotosocial.tls=true"
      - "traefik.http.routers.gotosocial.tls.certresolver=letsencrypt"
      - "traefik.http.services.gotosocial.loadbalancer.server.port=8080"

networks:
  gotosocial_net:
    external: true   # or however your existing network is declared

Then docker compose up -d.

Not using Traefik?

Same idea: wherever your nginx / Caddy / HAProxy config currently sends traffic to GoToSocial, send it to the sidecar instead, and set BACKEND to GoToSocial. Nothing else changes — TLS, headers and hostnames stay exactly as they were.

Configuration

Variable Default What it does
GTS_DOMAIN (required) Your instance's public domain, e.g. social.example.org. A scheme and trailing slash are fine. Used to build the placeholder image URL.
BACKEND http://gotosocial:8080 Where GoToSocial actually listens.
LISTEN :8080 Address the sidecar listens on.
EMOJI_PLACEHOLDER_URL derived from GTS_DOMAIN Full URL of your own placeholder image, if you would rather not use the built-in one. Overrides GTS_DOMAIN.

GTS_DOMAIN is required unless you set EMOJI_PLACEHOLDER_URL. The sidecar refuses to start with neither, rather than serving a URL that points nowhere.

Verify

The placeholder image should be reachable through your reverse proxy:

curl -sI https://social.example.org/_emoji-fix/missing.png | head -1
# HTTP/2 200

And a timeline should no longer contain any empty emoji URLs:

curl -s -H "Authorization: Bearer $TOKEN" \
  "https://social.example.org/api/v1/timelines/home?limit=50" \
  | grep -c '"url":"","static_url":""'

Should print 0. Streaming still works too — /api/v1/streaming upgrade responses are passed through untouched, so no separate route is needed.

Notes and caveats

  • The placeholder is served by the sidecar, so the sidecar must be in the request path for GTS_DOMAIN. If it is not, point EMOJI_PLACEHOLDER_URL at an image you host.
  • It only fills in empty fields. Correct emoji are never touched, and clients that already tolerate the bug see no difference.
  • The public Host header is deliberately passed through unchanged. Federation signs it as part of the HTTP Signature, and rewriting it makes GoToSocial reject remote servers with 401.
  • JSON responses are buffered in memory to do the replacement. Media and file downloads are not JSON, so they stream through as usual.
  • The sidecar speaks plain HTTP. Keep it behind whatever terminates TLS today.
  • To remove it: move the Traefik labels (or your proxy's upstream) back to GoToSocial and delete the sidecar container.

Build from source

git clone https://git.pickysysadmin.ca/eric/gts-emoji-fix-sidecar.git
cd gts-emoji-fix-sidecar
go test ./... && go build .
GTS_DOMAIN=social.example.org BACKEND=http://localhost:8080 ./gts-emoji-fix-sidecar

Images are published for linux/amd64 and linux/arm64.

License

GPL-3.0-or-later. See LICENSE.