- JavaScript 68.6%
- CSS 28.9%
- HTML 2.4%
- Python 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| public | ||
| screenshots | ||
| tests | ||
| tools | ||
| .gitignore | ||
| AGENTS.md | ||
| Caddyfile.example | ||
| CHANGELOG.md | ||
| DEV_NOTES.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| replies_example.png | ||
chan-fe
Imageboard-style frontend for Mitra and Mastodon-compatible fediverse APIs.
chan-fe renders timelines as board catalogs with thread views, reactions, polls, media galleries, and posting tools. The application is a set of static files and does not require a server-side runtime of its own.
Status and compatibility
chan-fe is experimental, but actively usable for daily browsing and posting on a Mitra instance.
Mitra is the primary target and the only server exercised against in earnest. Several features use Mitra extensions, including custom post titles, emoji reactions, conversation visibility, and group-backed boards.
Mitra v5.8.0 or newer is recommended. Catalogs fetch the thread OPs a
timeline page omits, and on v5.8.0+ those land in a single batched
GET /api/v1/statuses?id[]=… request (up to 200 ids) using each status's
conversation.root_id. Older instances answer 404 there and fall back to
fetching one OP at a time — everything still works, but a busy board costs
substantially more requests to load.
The frontend otherwise uses Mastodon-compatible /api/* and /oauth/*
endpoints. OAuth sign-in against Pleroma has worked, but broader
Pleroma/Mastodon compatibility has not been tested extensively. Expect rough
edges outside Mitra.
Post references and quotes
chan-fe presents every reference to another post the same way — a red >>
quotelink that previews on hover and expands in place when clicked, labelled
(You), (Cross-thread) or (Dead) as it applies. The posts an OP quotes
start out expanded, so a thread opened from the catalog shows what it is about;
clicking the ref collapses one again. Two different things
are written to the wire, because replying and quoting mean different things to
every other frontend.
Replying. Clicking a post number opens the composer with >>ref in the
body; clicking another while it's open appends a second. They are sent as
plain markdown links to the referenced posts, so a reader on any server gets a
working link and the post still reads as a reply. Only the author of the post
being replied to is notified automatically (Mitra's prepare_mentions), so
each further reference carries an @mention — chan-fe hides it next to the ref.
Quoting. Quote post and Copy >>ref write Mitra's
[[<object-url>|>>ref]] microsyntax, which federates as a real quote
(FEP-e232
object links) — several per post, up to Mitra's limit of 10. Other frontends
render a quote as an embedded card, which suits "talking about this post" and
is why replies don't use it. Note that quoting notifies nobody, on any server.
Two constraints come from Mitra and shape what the composer offers: only public posts can be quoted, and only public posts may carry quotes. A reference in a followers-only or direct post is posted as a plain markdown link instead, and quoting is offered as public-only.
Servers other than Mitra don't parse the microsyntax, so signing in to one
falls back to plain markdown links plus the single-quote quote_id parameter.
In the other direction Pleroma, Mastodon, and Misskey keep the first reference
as their one quote and show the rest as links in the post body — which is what
chan-fe renders them as anyway.
Highlights
- 4chan-style board catalogs and chronological thread views
- Mitra group-backed custom boards
>>post references with hover previews, and multi-post quotes (FEP-e232)- OAuth sign-in, posting, replies, editing, moderation, polls, and media uploads
- Likes, reposts, bookmarks, and Unicode/custom emoji reactions
- Inline reply and profile previews, media galleries, and opt-in media embeds
- Local hashtag catalogs linked from posts and federated search results
- Thread watcher and paginated fediverse notifications
- Configurable NSFW handling, activity previews, mention collapsing, and an Anonymous presentation mode
- No required build step
See CHANGELOG.md for the detailed initial feature inventory and future release notes.
Installation
Clone the repository onto the machine that will serve the frontend:
git clone https://codeberg.org/nak/chan-fe.git
cd chan-fe
The simplest deployment is to let Mitra serve chan-fe itself by pointing its
web_client_dir setting at this public/ directory — chan-fe then replaces
mitra-web as the instance's main frontend. See
Serve chan-fe from Mitra directly.
Alternatively, serve public/ from a separate HTTPS origin whose hostname is
the default Mitra instance, and reverse-proxy /api/*, /oauth/*, and
/media/* to Mitra. Either keeps normal API requests same-origin while still
allowing users to sign in to other compatible instances directly.
No Node.js installation or build step is required when serving public/
directly.
Configuration
Frontend configuration lives in public/config.js. It does not contain generated credentials or secrets; OAuth applications are registered dynamically with the selected instance.
OAUTH_CONFIG.defaultInstance defaults to the hostname serving chan-fe. This
is correct when that hostname reverse-proxies the Mitra endpoints described
above. If the frontend uses a different hostname, either set the default in
public/config.js or provide it to the optional production build:
npm run build -- --default-instance example.com
The configured instance is only the sign-in dialog's default. Users may still enter another compatible instance.
Board definitions, labels, and timeline API paths also live in
public/config.js.
Deployment
Serve chan-fe from Mitra directly
The simplest way to run chan-fe as your instance's main frontend is to let
Mitra serve it, replacing its default web client. Clone the repository onto
the Mitra host and point Mitra's top-level web_client_dir setting at the
public/ directory:
# Mitra config.yaml
web_client_dir: /srv/chan-fe/public
Restart Mitra. It now serves chan-fe at its own origin, so /api/*,
/oauth/*, and /media/* are same-origin and OAUTH_CONFIG.defaultInstance
— which defaults to the serving hostname — is correct with no extra
configuration. No separate web server or reverse proxy is needed; sign-in to
other compatible instances still works directly at their own origins.
For a minified bundle, run the production build and point web_client_dir at
dist/ instead:
npm install
npm run build
# then set web_client_dir to the dist/ directory, e.g. /srv/chan-fe/dist
Serve the static files directly
Point the web root at public/. Route API requests before the static-file SPA
fallback:
- Reverse-proxy
/api/*,/oauth/*, and/media/*to Mitra. - Serve existing static files.
- Fall back to
index.htmlfor client-side routes.
Caddyfile.example contains a reference configuration with the required route order and suggested security headers. Copy the relevant blocks into your existing Caddy configuration rather than replacing it wholesale.
If the static-file handler runs before the proxy rules, API requests may
receive index.html instead of JSON.
Optional production build
The optional build bundles and minifies JavaScript into dist/app.js, writes
external source maps, and copies the other static assets into dist/. Large
on-demand features such as the complete emoji search index are emitted as lazy
chunks under dist/chunks/:
npm install
npm run build
Serve dist/ instead of public/ after building. To bake in an instance on a
different hostname:
npm run build -- --default-instance example.com
Deployment helper
The deployment helper builds and uploads dist/ with rsync:
npm run deploy -- example.com user@frontend.example:/var/www/chan-fe/
It uses --delete and --delay-updates. Check the destination carefully:
files at the destination that are absent from dist/ will be removed.
Screenshots
| Catalog | Thread view |
|---|---|
![]() |
![]() |
| Compose | Emoji picker | Media gallery |
|---|---|---|
![]() |
![]() |
![]() |
Development
There is no required development build step. Application code lives in
public/ and is plain JavaScript, HTML, and CSS.
Local static server
python3 tools/serve.py --port 8080
# open http://localhost:8080/
tools/serve.py sends Cache-Control: no-cache so browser refreshes reliably
pick up JavaScript and CSS edits.
You can also use Python's standard static server:
python3 -m http.server 8080 --directory public
When developing on localhost, set the default instance in public/config.js
or use the production build flag described above. The live instance must allow
the required cross-origin API requests unless a local reverse proxy is used.
Project layout
public/
├── index.html # app shell and modals
├── style.css # application styles
├── config.js # OAuth, board, and endpoint configuration
├── catalog.js # catalog sorting and thread-root helpers
├── threading.js # thread-root helpers for contexts and parent maps
├── media.js # media size, dimension, URL, and format helpers
├── gallery.js # multi-attachment gallery overlay
├── data.js # API access and timeline hydration
├── emoji.js # emoji search helpers and curated aliases
├── emoji-data.js # generated Unicode/CLDR emoji search index
├── UNICODE-LICENSE.txt # license for the Unicode/CLDR source data
├── app.js # routing, state, rendering, compose, auth, and interactions
├── images/ # local presentation assets
├── css/ # vendored Font Awesome stylesheets
└── webfonts/ # vendored Font Awesome font files
tools/
├── build.mjs # optional production bundle
├── update-emoji-data.mjs # regenerate the pinned emoji search index
├── deploy.sh # build and rsync helper
└── serve.py # local no-cache static server
tests/
├── catalog.test.js
├── data.test.js
├── security.test.js
└── threading.test.js
screenshots/ # README screenshots
Testing
The Node built-in test suite covers the core pure-logic modules and URL-scheme security checks:
npm test
To refresh the committed emoji index after intentionally updating the pinned
Unicode Emoji and CLDR versions in tools/update-emoji-data.mjs:
npm run emoji:update
Watch mode:
npm run test:watch
See DEV_NOTES.md for architecture notes and browser verification guidance.
Security
- Fediverse post, profile, and instance HTML passes through a local tag, attribute, and URL-scheme allowlist before reaching the rendered DOM.
- YouTube and PeerTube frames are created only after an explicit click.
PeerTube URLs must be HTTPS canonical
/w/<video-id>links, are converted to constrained embed URLs, and run in a sandbox without forms, popups, top-level navigation, or downloads. - OAuth bearer tokens are stored in
localStorage. This keeps the static app simple, but means any same-origin script execution could read the token. Serve chan-fe over HTTPS, retain a restrictive CSP, do not add untrusted third-party scripts, and keep the frontend origin under your control. - The API wrapper attaches a bearer token only to the selected signed-in instance origin or the configured same-origin reverse proxy.
PeerTube embeds may originate on any HTTPS instance, so the example CSP uses
frame-src https:. Tighten connect-src, img-src, media-src, and
frame-src when deploying against a fixed set of instances.
Reference projects
- bloat-fe — authentication flow inspiration
- mitra-web — API behavior reference
- FChannel — federated imageboard server with a different architecture
License
chan-fe is released under the MIT License.




