- Go 95.8%
- Dockerfile 4.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| assets | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| go.mod | ||
| LICENSE | ||
| main.go | ||
| main_test.go | ||
| README.md | ||
| SECURITY.md | ||
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-Encodingso 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, pointEMOJI_PLACEHOLDER_URLat 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
Hostheader 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.