Skip to content

About

The main PostyFox core components

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

PostyFox Core Platform (container reimplementation)

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).

Status

Media/image delivery is implemented across all connectors (upload → object store → per-platform upload). Residuals (alt text, video, limits) are in docs/FOLLOWUPS.md.

Architecture

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

Projects

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)

The connector contract

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
Instagram 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
Twitch 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.

External triggers

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.

Posting 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.

Localisation

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.

Run locally

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/health

Auth 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).

Build & test

# 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.Infrastructure

Tests use in-memory SQLite / EF-InMemory and fakes for I/O. No Docker required to run them.

ImageSharp licence

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:

  1. SIXLABORS_LICENSE_KEY env var holding the full .lic file contents (how CI supplies it).
  2. sixlabors.lic in 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.lic

Deploy

All 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/postyfox for any Kubernetes (helm install postyfox deploy/helm/postyfox); backing services (Postgres/RabbitMQ/object store) provided externally via values.
  • Terraform → Azure Container Apps: deploy/terraform-aca deploys 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.

Configuration (env vars)

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.

Known follow-ups

  • Security advisories are resolved by explicit version pins (no audit suppressions): Microsoft.OpenApi → 2.7.5 in the API projects (the framework's AspNetCore.OpenApi 10.0.9 otherwise pulls the vulnerable 2.0.0 transitively), and SQLitePCLRaw.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.

About

The main PostyFox core components

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages