SFX framework
SFX is a streamlined, full-stack Rust framework for building small web services with integrated authentication, localization, and config-driven UI components
SFX is a simplified framework for building full-stack, small service.
The framework now allows external auth, local auth and we will soon add OAuth into it
The framework is built using hotaru. Read more about hotaru in https://fds.rs/hotaru/.
You may import hotaru directly from use sfx::prelude or use sfx::hotaru
Use sfx --help to learn how to use built-in tools in sfx, while run sfx init in the target dir to initialize a new project
https://fds.rs/sfx/tutorial/0.1.3/
Settings & Op
Endpoints
/op/lang/<lang>
Change the user's language by setting a cookie and redirecting back to the
page they came from (resolved via the Referer header — see
op::from(req)). This may not work if running in http but not https.
Request
GET /op/lang/<lang>
EMPTY
Response
A HttpResponse that redirects to the previous page with the new language set in a cookie
Language resolution
op::lang(req) (used by pageprop, into_path_l, etc.) resolves in this
order, accepting a value only if it appears in support_lang.json:
?lang=<code>query parameter — used by crawlers and<link rel="alternate" hreflang>so each language has its own crawlable URL.langcookie — set by the footer language switcher for human users.Accept-Languageheader — negotiated viahtmstd::PreferredLanguage::best_matchagainstsupport_lang.json. Quality, header order, and supported-list order are honored per RFC 9110.default_lang()— the first entry insupport_lang.json.
op::lang_or_none(req) returns None at step 4 instead of the default, so
downstream apps can insert their own fallback (e.g. a /<code>/... URL
prefix scheme) between SFX's negotiation layer and the site default.
The Accept-Language layer requires PreferredLanguageMiddleware in the
protocol stack. SFX's bundled APP installs it automatically and configures
its fallback to match default_lang(). Downstream apps that build their own
APP should also append PreferredLanguageMiddleware (re-exported from
sfx::prelude) for the layer to take effect.
Handlers that want typed access to the parsed header can call
req.params.get::<htmstd::PreferredLanguage>() directly (or via the
PreferredLanguageRequestExt trait) — useful for non-template scenarios
like API content negotiation.
/static/<path>
Serves the static files
Request
GET /static/<path>
EMPTY
Returns
A HttpResponse containing the static file or a 404 error if not found
/redirect?url=<url>
Redirects to a given URL
Request
GET /redirect?url=<url>
EMPTY
Returns
A HttpResponse that redirects to the specified URL
/robots.txt
Serves crawler directives. Reads programfiles/op/robots.txt from the
current working directory if present, otherwise returns a built-in default
that disallows /user/ and /admin/. Consumers can override by shipping
their own file at that path.
Request
GET /robots.txt
EMPTY
Returns
A text/plain HttpResponse with the robots directives.
Settings
The framework loads critical configuration files at startup from programfiles/op/ and programfiles/admin_info/ directories. These include:
UI Components
navbar.json and footer.json define site-wide UI elements localized by language keys.
Navbar.json is located at ./programfiles/op/navbar.json
The root is a dictionary. The keys are languages, while the values are the content of navbar for different languages
For the content, there must be 2 content, name and itemlist. Name will be shown in the Logo area while the item lists are the items shown on the navbar
For items in the item list, there are two kinds. The first kind is a single link item
,
Another kind is dropdown, if you click the item the subitems contained in this item will be shown
Footer Configuration (footer.json)
Located at ./programfiles/op/footer.json, this file defines localized footer content. The root is a dictionary where keys are language codes and values are footer configurations.
Structure
Components
-
Footer Column (
itemsarray):name: Column title (e.g., "Support", "Resources")itemlist: Array of link objects:
-
Footer HTML (
footer):- Raw HTML string for copyright/legal text
- Supports inline styling and anchor tags
- Example:
"footer": "© 2025 <a href=\"/about\">Company</a>"
Full Example
Localization
l10n.json stores translated strings, support_lang.json lists supported languages (first entry is default).
Localization Configuration (l10n.json)
Located at ./programfiles/op/l10n.json, this file provides localized string translations. The root is a dictionary where keys are phrase identifiers and values are language-specific translations.
Structure
Example Usage
// In template rendering:
get_localized_string // Returns "登录" for zh
Best Practices
- Keep keys short and semantic (
"nav_home"vs"homepage_link") - Maintain consistent casing (lowercase recommended)
- Add new languages to
support_lang.jsonfirst - Escape special characters (
\n,\",\\)
// Minimal complete example
Security
hosts.json contains trusted origins (checked via is_trusted()), admins.json holds administrator data.
Hosts.json located at ./programfiles/op/hosts.json
It is a Json List. The order in the list will change the order different hosts present in Login, the 0th element will be the default host
A special string "local" indecates we use local host
Minimal example:
Network
binding.txt specifies server binding address (default: localhost:3003).
Directly write your location in programfiles/op/binding.txt
User Login & Operations
User Endpoints
User Endpoints
1. Login Flow
GET /user/login[?next=<path>]
Renders login page with language-specific content.
The optional ?next=<path> query carries the URL the user should land on
after a successful login. The shipped login.html JS picks a post-login
redirect target in this order:
?next=<path>on the login URL, if it is a same-origin path (starts with/and not//).document.referrerif it is same-origin and not/user/loginitself./as a safe fallback.
This is also the target for op::forbidden_response's "Log in" link, so a
403 → login → original page round trip preserves the user's location.
Response: HTML page with:
- Localized navbar/footer
- Host selection dropdown (from
hosts.json) - Login form
POST /user/login
Authenticates user credentials
Parameters (URL-encoded form):
host: Authentication server ("local" for localhost)username: User identifierpassword: Plaintext password
Responses:
// Success response is generated the backend. Please refer to the Local Auth
// Failure
Sets Cookies:
access_token: JWT for authenticated sessionshost: Base authentication server
2. Session Management
GET /user/logout
Invalidates current session
Clears: Access token cookie
Redirects: To login page
GET /user/refresh?redirect=<url>
Refreshes access token
Redirects: To specified URL after refresh
GET /user/refresh_api (Testing)
Returns new access token
Response:
3. User Information
GET /user/cached_info
Returns cached user data from session
Response:
GET /user/info (Testing)
Fetches fresh user info from auth server
Response: Raw user object
4. Protected Routes
GET /user/home
User dashboard (requires valid session)
Redirects: To /user/login if unauthenticated
Renders: user/home.html with:
- Localized page title/description
- Breadcrumb navigation
- Current user object
GET /user/unauthorized
Access denied page
Renders: user/unauthorized.html
op::pageprop / op::pageprop_with_keywords
pageprop(req, title, description) builds the standard page properties
(lang, title, description, nav, foot, user, path, etc.) and
leaves <meta name="keywords"> empty.
To populate per-page SEO keywords without rebuilding the dict, call
pageprop_with_keywords instead:
use op;
let pp = pageprop_with_keywords;
The four-argument form is the canonical entry point; pageprop is the
zero-keywords convenience wrapper.
op::forbidden_response(req, message) helper
For permission-gated endpoints, prefer this helper over
text_response("403 Forbidden") so users stranded on a gated URL after
their session expires still see the site chrome.
use op;
if !user.can_edit
Returns an HttpResponse with status 403 whose body renders
user/forbidden.html with:
pageprop— standard page properties (navbar + footer + lang)message— caller-supplied explanation, or a generic default ifNonenext— the current request URL, percent-encoded, suitable for building a/user/login?next=<next>recovery link in the template
The shipped template provides "Log in" (→ /user/login?next=<current>)
and "Back to view" (→ the current path with query stripped) buttons.
5. Password Management
POST /user/home/change_password
Updates user password
Parameters (URL-encoded form):
old_password: Current passwordnew_password: New passwordhost: Authentication server
Responses:
// Success
// Failure
User API
This module provides core utilities for managing user sessions, authentication tokens, and interactions with the authentication server.
Key Functions
Token Management
set_auth_token(req: &mut HttpReqCtx, token: &str)
Stores the JWT token in the session under"auth_token".get_auth_token(req: &HttpReqCtx) -> Option<String>
Retrieves the JWT token from the session, if present.set_host(req: &mut HttpReqCtx, host: &str)
Stores the authentication server host (e.g.,"auth.fds.moe"or"local") in the session.get_host(req: &HttpReqCtx) -> Server
Retrieves the authentication server host from the session, falling back toServer::Localif missing.
User Data Fetching
fetch_user_info(host: Server, auth: String) -> Option<User>
Fetches user details from/users/meendpoint.
Response format:
Session Operations
refresh_user_token(req: &mut HttpReqCtx) -> Value
Refreshes access token via/auth/refresh. Updates session token on success.
Success response:cache_user_info(req: &mut HttpReqCtx, user: User)
Caches deserialized user object in session.logout(req: &mut HttpReqCtx) -> HttpResponse
Clears session tokens and redirects to login flow.disable_token(host: Server, token: String) -> Value
Invalidates token server-side via/auth/logout.
Utilities
request_with_auth_token()
AddsAuthorization: Bearer <token>header to requests.get_user()
Retrieves current user from request params (guest if unauthenticated).auth_server_health()
Checks/healthendpoint (returnstruefor{ "status": "ok" }).
Security Features
- HTTP-only Cookies
Tokens are stored withhttp_onlyflag to prevent XSS access. - Automatic Token Refresh
/auth/refreshmaintains session validity without re-login. - Server-side Invalidation
Tokens are disabled on logout via auth server. - Guest Fallback
Unauthenticated requests returnUser::guest().
Flow Example
TBD
Admin Endpoints
The admin surface is split by content type: /admin/panel/* returns HTML,
/admin/users/* and /admin/admins/* return JSON. All endpoints check
check_is_admin first (the current request's UserID must appear in
programfiles/admin_info/admins.json). HTML pages redirect non-admins to
/user/unauthorized; JSON endpoints return 401 Unauthorized.
1. Pages (HTML)
GET /admin/
Admin dashboard landing page.
Renders: admin/index.html with localized page chrome.
GET /admin/panel
User-management list.
Renders: admin/panel.html, which loads /admin/users/json client-side
and paginates 10 rows per page.
Columns: UID, Username, Email, Active, Action.
Links: per-row "Edit" → /admin/panel/<uid>; "Manage admins" →
/admin/panel/admins.
GET /admin/panel/<uid>
Edit page for one user. Returns 404 (HTML) if the uid doesn't exist.
Renders: admin/user_edit.html with three forms:
- Edit username / email / active →
POST /admin/users/<uid> - Reset password →
POST /admin/users/<uid>/password - Delete (
confirm()first) →POST /admin/users/<uid>/delete
All three forms submit as application/x-www-form-urlencoded via JS and
display the JSON response inline.
GET /admin/panel/admins
Admin-membership management page.
Renders: admin/admins.html. Lists entries from admins.json and
supports add/remove via the JSON API.
2. User Management API (JSON)
GET /admin/users
List all locally-stored users.
Response:
is_admin is computed per request from admins.json; it is not a stored
field on UserStorage.
POST /admin/users
Create a new local user.
Parameters (URL-encoded form):
username: New user's identifieremail: Email addresspassword: Plaintext password (hashed server-side before storage)
Responses:
// Success (201 Created)
// 400 Bad Request — format validation failed
// 409 Conflict — value owned by another user
Maps FopError variants to: 400 (UserNameNotValid / EmailNotValid /
PasswordMismatch), 409 (UserNameConflict / EmailConflict), 429
(TooManyRequest), 500 (anything else, logged via tracing::error!).
GET /admin/users/json
Identical payload to GET /admin/users; kept as the panel JS's stable
endpoint name.
GET /admin/users/<uid>
Single-user JSON.
Responses:
// 200 OK
// 404 Not Found
POST /admin/users/<uid>
Edit an existing user. All fields optional — only present fields are
applied.
Parameters (URL-encoded form, all optional):
username: New usernameemail: New emailis_active:"true" | "on" | "1" | "yes"→true, anything else →false
Same-uid uniqueness exception: passing the user's existing username or
email is allowed (no-op for that field). A duplicate value owned by a
different uid returns 409.
Responses:
// 200 OK
// 404 Not Found / 400 Bad Request / 409 Conflict
POST /admin/users/<uid>/password
Reset the user's password.
Parameters (URL-encoded form):
new_password: Plaintext password (hashed server-side)
Responses:
// 200 OK
// 400 Bad Request (empty password) / 404 Not Found
POST /admin/users/<uid>/delete
Delete a user. Returns 404 if the uid doesn't exist.
Responses:
// 200 OK
// 404 Not Found
3. Admin Membership API (JSON)
GET /admin/admins/json
List current admin entries.
Response:
POST /admin/admins
Add an admin entry.
Parameters (URL-encoded form):
uid: Either"3"(defaults to@local) or"3@local"(fully qualified). Must reference an existing local user for the@localcase.
Responses:
// 200 OK — idempotent: re-adding an existing entry succeeds silently
// 400 Bad Request — malformed or unknown uid
POST /admin/admins/<entry>/delete
Remove an admin entry. <entry> must be URL-encoded (e.g. 1%40local
for 1@local).
Responses:
// 200 OK — also idempotent for missing entries
// 400 Bad Request
4. Backend additions
AuthManager (in src/local_auth/fop.rs)
New uid-keyed admin methods. None take a caller token — gating happens at the endpoint layer:
admin_list_users() -> Vec<(u32, UserStorage)>
Enumerates all users with their uid; uid-sorted.admin_get_user(uid) -> Option<UserStorage>
Single lookup;Noneif missing.admin_edit_user(uid, new_username, new_email, new_is_active) -> Result<(), FopError>
Partial update with same-uid uniqueness exception. Holdsusername_map,email_map,userswrite locks atomically; format checks run before the lock acquisition, conflict checks after.admin_reset_password(uid, new_password) -> Result<(), FopError>
Empty password →PasswordMismatch.admin_delete_user(uid) -> Result<(), FopError>
Removes fromusers,username_map, andemail_mapatomically. Does not clean orphanadmins.jsonentries (tracked inTICKETS.md).
UserStorage (in src/local_auth/fop.rs)
New field:
pub is_active: bool
Defaults totrueon load from disk for backward compatibility with pre-0.1.3 user files. Inactive users cannot authenticate via/auth/login,/auth/refresh, or/users/me.
FopError (in src/local_auth/fop.rs)
New variants:
UserNameConflict,EmailConflict— value owned by another uid. Surfaced by the API as409 Conflict, distinct from the format- validation400produced byUserNameNotValid/EmailNotValid.
op module (in src/op.rs)
Admins are now mutable at runtime. ADMINS switched from
Lazy<Value> to Lazy<RwLock<Value>>, and get_admin() returns an
owned clone rather than &'static Value:
get_admin() -> Value
Returns a clone of the current admin snapshot.read_admin_entries() -> Vec<String>
Convenience: the snapshot asVec<String>.add_admin_entry(entry: &str) -> std::io::Result<()>
Idempotent. Persists toprogramfiles/admin_info/admins.jsonfirst, then updates the in-memory snapshot.remove_admin_entry(entry: &str) -> std::io::Result<()>
Same write-through ordering.
RedirectNonAdmin middleware
Unchanged. Insert after UserFetch in your protocol stack to gate any
custom admin route. Non-admin requests are redirected to
/user/unauthorized.