Cloud-agnostic, containerised reimplementation of PostyFox.
Documentation: docs/ARCHITECTURE.md (as-built architecture + diagrams) · docs/DECISIONS.md (ADRs) · docs/README.md (index + endpoint reference) · docs/FOLLOWUPS.md (deferred work).
Media/image delivery is implemented across all connectors (upload → object store → per-platform upload). Residuals (alt text, video, limits) are in docs/FOLLOWUPS.md.
| Concern | Choice | Abstraction |
|---|---|---|
| Datastore | PostgreSQL (EF Core / Npgsql) | IAppDbContext |
| Object storage | S3-compatible (MinIO / RustFS local) | IObjectStore |
| Message bus | RabbitMQ (delayed-message exchange for scheduling + backoff) | IMessageBus |
| Secrets | Pluggable provider (adapters-secrets) |
ISecretsProvider |
| AuthN | oauth2-proxy → Keycloak, header identity; or X-API-Key (hashed) |
PostyFox.Web.Auth |
| Observability | OpenTelemetry → OTLP collector | N/A |
src/
PostyFox.Domain entities + enums (no deps)
PostyFox.Application abstractions, services, template engine, pipeline handlers, connector contract
PostyFox.Infrastructure EF Core + migrations, S3, RabbitMQ, secret-store provider (adapters-secrets), Discord connector
PostyFox.Web shared auth (oauth2-proxy header + API key) + OpenTelemetry wiring
PostyFox.Api.Core profile/keys, services catalogue, connector CRUD, template CRUD
PostyFox.Api.Post post intake + status, external-trigger webhook callback
PostyFox.Worker.Posting consumes generate/deliver queues; runs the pipeline
connectors-node/ Node/TS service: Bluesky + Tumblr + FurAffinity + Toyhouse + X + Instagram + Fediverse
clients/
postyfox-connect/ Chrome/Edge extension + Safari iPhone/iPad/macOS conversion scaffold
tests/ one project per layer (xUnit), 99 C# tests (+18 in connectors-node)
Every platform implements IConnector (Describe/IsAuthenticated/ListTargets/Deliver).
Adding a platform = implement IConnector + a ServiceDefinition row.
| Platform | Stack | Library / mechanism |
|---|---|---|
| Discord | .NET in-process | webhook HTTP |
| Telegram | .NET in-process | MTProto user account via WTelegramClient (blob-backed session; see §4.5 statefulness note) |
| Bluesky | Node service | @atproto/api, via the HttpConnector adapter over internal HTTP (X-Internal-Token) |
| Tumblr | Node service | tumblr.js, same adapter; OAuth1 connect flow |
| FurAffinity | Node service | Authenticated HTML forms; browser session paired by PostyFox Connect |
| Toyhouse | Node service | Authenticated HTML forms; browser session paired by PostyFox Connect |
| X | Node service | rettiwt-api over the user's browser session, paired by PostyFox Connect. Breaks X's terms of service on automated access and can get the account suspended |
| Ko-fi | Node service | Authenticated site forms (no API); browser session paired by PostyFox Connect; text posts and image gallery posts |
| SoFurry | Node service | Documented public API; OAuth2 + PKCE connect flow with automated refresh-token renewal; image submissions (title and rating required), optionally filed into folders |
| Artconomy | Node service | The site's own JSON API with a browser session paired by PostyFox Connect; one-image submissions (rating and at least 5 tags required) and text journals |
| Node service | Instagram Content Publishing API (Business Login for Instagram), same adapter; OAuth2 connect flow with automated long-lived-token refresh; media must be publicly fetchable, so bytes are staged via a presigned object-store URL rather than uploaded directly | |
| Fediverse (Mastodon, Pleroma, Akkoma, Friendica, Iceshrimp, GoToSocial, Hometown, Pixelfed) | Node service | megalodon, same adapter; one generic connector, SNS auto-detected per instance; OAuth2 / MiAuth connect flow |
| N/A | descoped |
connectors-node exposes an IConnector-shaped HTTP contract (/connectors/:platform/{is-authenticated,list-targets,limits,deliver}); the C# HttpConnector forwards to it, passing the resolved config + secret in the request so the Node side stays stateless. Connector auth/target operations are exposed at GET /api/connectors/{id}/authenticated, GET /api/connectors/{id}/targets, and the Telegram login flow at POST /api/connectors/{id}/telegram/login.
Scraper-backed login is a one-click hand-off from the PostyFox Connect browser extension. Because the
extension carries the user's own PostyFox session, it takes two calls and no user input:
GET /api/connectors/cookie-pairing/targets (which connector, which cookies, which login page) then
POST /api/connectors/cookie-pairing/pair. Only the cookie names the platform's CookiePairingSpec
declares are read or stored, and the connector is created on the spot if the user has none. The
five-minute one-use pairing token (/cookie-pairing/start → /cookie-pairing/complete, with public
site metadata at /cookie-pairing/sites) remains as a fallback for a browser that cannot present a
PostyFox session. See clients/postyfox-connect.
Per-submission platform options. Some platforms take choices that describe the submission rather
than the account: FurAffinity's category, theme, species, gender and gallery folders; Toyhouse's
character IDs, artist credit and privacy/watermark choices; every Fediverse platform's content
warning. A connector declares these as field descriptors on
ConnectorDescriptor.PostOptionsSchema (surfaced as postOptionsSchema on GET /api/services); the
compose form renders them once per selected target and sends them as targetOptions on
POST /api/posts, keyed by connector id. They are validated against the same schema at intake, stored
on the PostTarget, and applied over the connector's config when delivery builds its
ConnectorContext, so connectors keep reading a single config object. Options lists too large for a
C# literal live in Persistence/Schemas.
Content warnings (Fediverse). Mastodon, Pleroma, Akkoma, Friendica, Iceshrimp, GoToSocial,
Hometown and Pixelfed all support a click-to-reveal content warning (Mastodon's API calls this
spoiler_text; megalodon maps it to Misskey's cw for the firefish driver Iceshrimp uses), declared via
ConnectorDescriptor.SupportsContentWarning and surfaced as the "Content warning" capability badge on
GET /api/services. It is an explicit, optional per-submission field (ContentWarning in
PostOptionsSchema), never derived from the post title, which is an unrelated field most of these
platforms don't even render. No other connector (Bluesky, Tumblr, FurAffinity, Toyhouse, Instagram,
Discord, Telegram) has an equivalent mechanism.
Per-instance limits. Fediverse instances each configure their own caps, so the static per-platform MaxContentLength is only a fallback hint. GET /api/connectors/{id}/limits reports the connector's real limits ({ maxContentLength, maxMediaAttachments, supportedMimeTypes, imageSizeLimit, videoSizeLimit }, sizes in bytes), fetched live from the instance (getInstance()) for Fediverse connectors via the optional ILimitsConnector capability, falling back to the descriptor value for others. Delivery enforces these limits and fails clearly (no silent truncation) if a post exceeds the instance's character count, attachment count, an unsupported media MIME type, or a media file-size cap.
Source-agnostic: an ITriggerSource encapsulates each source's signature scheme + payload shape (a
generic HMAC-signed webhook source ships built-in). Register interest via POST /api/triggers;
inbound events hit POST /api/webhooks/{sourceType} (anonymous) → signature-verified → deduped by
message id → fanned out to matching triggers, each throttled by NotifyFrequencyHrs, creating a
templated post through the normal pipeline.
POST /api/posts persists a root post + one PostTarget per connector, stores payload/media, and
publishes a generate command per target. The worker renders each target (template engine), then
publishes a deliver command; delivery routes to the connector, captures the external id/url, and
rolls the per-target status up into the root status (Delivered / PartiallyFailed / Failed).
Transient delivery failures retry with exponential backoff (delayed re-publish) up to
Pipeline:MaxDeliveryAttempts, then fail. GET /api/posts/{id} returns aggregated status.
User-facing API messages live in PostyFox.Application/Resources/Messages.resx (en-GB, the
default) and are used through the generated Messages class. Both APIs pick the culture from the
request's Accept-Language (UsePostyFoxLocalization), falling back to en-GB. To add a language,
add Messages.<culture>.resx and the culture to WebExtensions.SupportedCultures. Delivery errors
from platforms and connector field labels are not localised.
The .NET images need the ImageSharp licence: copy sixlabors.lic to the repo root before building
(see ImageSharp licence).
cd deploy
docker compose up --build # full stack incl. the OIDC edge (Keycloak + oauth2-proxy + gateway)
# Edge (log in here): http://localhost:4180 → fans out to core-api / post-api after OIDC login
# Keycloak: http://localhost:8082
# Swagger UI: http://localhost:8080/swagger and http://localhost:8081/swagger
# OpenAPI: http://localhost:8080/openapi/v1.json (and :8081)
# RabbitMQ: http://localhost:15672 MinIO console: http://localhost:9001
# connectors-node (Bluesky/Tumblr/FurAffinity/Toyhouse/X/Instagram/Fediverse): http://localhost:8090/healthAuth is always the production-representative OIDC path. There is no DevMode bypass. oauth2-proxy
performs the OIDC exchange against Keycloak and the APIs validate the forwarded Authorization: Bearer
token in-app, so reach them through the edge at http://localhost:4180. Hitting the APIs directly
(:8080/:8081) requires a valid bearer token. External/machine callers authenticate with
X-API-Key: <key> (create one via POST /api/profile/keys).
Browser login: open http://localhost:4180 and sign in as postyfox /
postyfox (Keycloak admin console at http://localhost:8082, admin / admin). Keycloak's issuer
is pinned to localhost:8082 (KC_HOSTNAME) so the browser and the in-cluster back channel stay
consistent; oauth2-proxy uses split front/back-channel URLs: see
deploy/oauth2-proxy/oauth2-proxy.cfg.
The RabbitMQ image build downloads the delayed-message-exchange plugin (needs network at build).
# from the repo root
dotnet build # whole solution (PostyFox.Platform.slnx)
dotnet test # all unit/integration tests
# Node connectors: cd src/connectors-node && npm ci && npm test
# EF migrations
dotnet dotnet-ef migrations add <Name> --project src/PostyFox.InfrastructureTests use in-memory SQLite / EF-InMemory and fakes for I/O. No Docker required to run them.
PostyFox.Infrastructure uses SixLabors.ImageSharp 4, which validates a Six Labors licence at
compile time. Without one, Debug builds warn and Release builds fail ("No Six Labors license
found"). The root Directory.Build.props resolves it in this order:
SIXLABORS_LICENSE_KEYenv var holding the full.licfile contents (how CI supplies it).sixlabors.licin the repo root (local dev). It is gitignored: never commit it.
For image builds, deploy/docker-compose.yml needs nothing extra: the root sixlabors.lic reaches
the Dockerfile's build stage via COPY . . and never reaches the final image. This works with
Docker and with Podman (classic builder). CI has no file and passes the licence as a build secret
instead, which works the same way locally:
docker build --secret id=sixlabors_license,src=sixlabors.lic \
--build-arg PROJECT=src/PostyFox.Api.Core/PostyFox.Api.Core.csproj \
--build-arg ASSEMBLY=PostyFox.Api.Core.dll --target final .In GitHub, the licence is the SIXLABORS_LICENSE_KEY repository secret, set for both Actions
and Dependabot (Dependabot PRs can't read Actions secrets). To rotate it:
gh secret set SIXLABORS_LICENSE_KEY -R PostyFox/postyfox-core < sixlabors.lic
gh secret set SIXLABORS_LICENSE_KEY -R PostyFox/postyfox-core --app dependabot < sixlabors.licAll modes consume the same published images ({registry}/{repository}-{service}:{tag}, built by
.github/workflows/build.yml):
- docker-compose:
deploy/docker-compose.yml(local / single-host). - Helm:
deploy/helm/postyfoxfor any Kubernetes (helm install postyfox deploy/helm/postyfox); backing services (Postgres/RabbitMQ/object store) provided externally via values. - Terraform → Azure Container Apps:
deploy/terraform-acadeploys the images to ACA (terraform apply), with external ingress for the APIs and internal for connectors-node.
Telemetry (OTLP) is exported to a collector and forwarded to central OpenSearch: see deploy/observability/README.md.
Nested keys use __. Key settings: ConnectionStrings__Postgres, ObjectStore__*, RabbitMq__*,
Secrets__Provider (InMemory | BitWarden | AzureKeyVault | Infisical; provider options under
Secrets__<Provider>__*, e.g. Secrets__BitWarden__ServerUrl), Auth__Oidc__Enabled / Auth__Oidc__Issuer /
Auth__Oidc__JwksUrl / Auth__Oidc__Audience, Auth__UserHeader,
NodeConnectors__BaseUrl + NodeConnectors__InternalToken (→ connectors-node; also set as
INTERNAL_TOKEN on that service; RATE_LIMIT_MAX on connectors-node caps requests per client IP per
minute, default 600), ApplyMigrations, SeedServiceDefinitions,
OTEL_EXPORTER_OTLP_ENDPOINT.
Trigger signing secrets live in the secret store under trigger-{sourceType}-signing. Administrators
with the Keycloak postyfox-admin realm role can manage connector operational credentials at
/admin. Telegram uses TelegramApiID / TelegramApiHash; Tumblr uses TumblrConsumerKey /
TumblrConsumerSecret; Instagram uses InstagramAppId / InstagramAppSecret; SoFurry uses SofurryClientId /
SofurryClientSecret. Values are written
to the configured secret provider and are never returned.
- Security advisories are resolved by explicit version pins (no audit suppressions):
Microsoft.OpenApi→ 2.7.5 in the API projects (the framework'sAspNetCore.OpenApi10.0.9 otherwise pulls the vulnerable 2.0.0 transitively), andSQLitePCLRaw.bundle_e_sqlite3→ 3.0.3 in the test projects (EF Core Sqlite otherwise pulls the flagged 2.1.11 native lib). - Scheduling uses the RabbitMQ delayed-message plugin; a durable scheduler / due-scan is a Phase 5 hardening item for very long-horizon schedules.
- Telegram MTProto is stateful: route a user's Telegram ops to a single instance (consistent hashing / dedicated telegram-worker).
- There is no endpoint yet to set platform-level secrets (e.g. Telegram api id/hash, trigger signing secrets), seed them into the secret store directly for now.