sfx 0.1.3

SFX is a streamlined, full-stack Rust framework for building small web services with integrated authentication, localization, and config-driven UI components
Documentation

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:

  1. ?lang=<code> query parameter — used by crawlers and <link rel="alternate" hreflang> so each language has its own crawlable URL.
  2. lang cookie — set by the footer language switcher for human users.
  3. Accept-Language header — negotiated via htmstd::PreferredLanguage::best_match against support_lang.json. Quality, header order, and supported-list order are honored per RFC 9110.
  4. default_lang() — the first entry in support_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

{ 
    "language-key": {"content"}, 
    "en": {".."}, 
    "zh-tw": {".."} 
}

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

{ 
    "en": { 
        "name": "ICON", 
        "itemlist": [
            ["..."], 
            ["..."] 
        ] 
    }
}

For items in the item list, there are two kinds. The first kind is a single link item

{
    "display": "Single",
    "url": "/",
    "is_dropdown": false // Indicates here 
}, 

Another kind is dropdown, if you click the item the subitems contained in this item will be shown

{
    "display": "List",
    "url": "/",             // Useless, but safer to keep it 
    "is_dropdown": true,    // Indicates it is a dropdown link 
    "dropdown": [           // Dropdown items 
        {
            "item": "Item 1",
            "iurl": "/"
        },
        {
            "item": "Item 2",
            "iurl": "/hotaru/news/"
        }
    ]
} 

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

{
  "language-key": {
    "items": [FooterColumn],
    "footer": "HTML string"
  }
}

Components

  1. Footer Column (items array):

    • name: Column title (e.g., "Support", "Resources")
    • itemlist: Array of link objects:
      {
        "display": "Link Text",
        "url": "/path-or-external-url"
      }
      
  2. Footer HTML (footer):

    • Raw HTML string for copyright/legal text
    • Supports inline styling and anchor tags
    • Example:
      "footer": "&copy; 2025 <a href=\"/about\">Company</a>"
      

Full Example

{
  "en": {
    "items": [
      {
        "name": "Support",
        "itemlist": [
          {"display": "Help Center", "url": "/support"},
          {"display": "Contact", "url": "/contact"}
        ]
      },
      {
        "name": "Language",
        "itemlist": [
          {"display": "English", "url": "/op/lang/en"},
          {"display": "日本語", "url": "/op/lang/ja"}
        ]
      }
    ],
    "footer": "&copy; 2025 MyApp | <a href='/privacy'>Privacy Policy</a>"
  }
}

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

{
  "phrase_key": {
    "language-code": "translated_text",
    "en": "English text",
    "zh": "中文文本",
    "ja": "日本語テキスト"
  }
}

Example Usage

// In template rendering:
op::get_localized_string("login", &lang) // Returns "登录" for zh

Best Practices

  1. Keep keys short and semantic ("nav_home" vs "homepage_link")
  2. Maintain consistent casing (lowercase recommended)
  3. Add new languages to support_lang.json first
  4. Escape special characters (\n, \", \\)
// Minimal complete example
{
  "welcome": {
    "en": "Welcome back!",
    "zh": "欢迎回来!",
    "ja": "おかえりなさい"
  },
  "error_404": {
    "en": "Page not found",
    "zh": "页面不存在",
    "ja": "ページが見つかりません"
  }
}

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:

["auth.fds.moe", "local"]

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:

  1. ?next=<path> on the login URL, if it is a same-origin path (starts with / and not //).
  2. document.referrer if it is same-origin and not /user/login itself.
  3. / 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 identifier
  • password: Plaintext password

Responses:

// Success response is generated the backend. Please refer to the Local Auth 

// Failure
{
  "success": false,
  "message": "Invalid credentials"
}

Sets Cookies:

  • access_token: JWT for authenticated sessions
  • host: 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:

{ "access_token": "new.jwt.token" }

3. User Information

GET /user/cached_info
Returns cached user data from session
Response:

{
  "uid": "user-id",
  "server": "auth.fds.moe",
  "username": "john_doe",
  "email": "user@example.com",
  "is_active": true,
  "is_verified": true,
  "cached_time": 1712345678
}

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 sfx::op;

let pp = op::pageprop_with_keywords(
    req,
    "Constitution",
    "The constitution of the project",
    "hotaru, akari, project starfall",
);

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 sfx::op;

if !user.can_edit() {
    return op::forbidden_response(req, Some("You need write permission to edit this resource."));
}

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 if None
  • next — 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 password
  • new_password: New password
  • host: Authentication server

Responses:

// Success
{ "success": true }

// Failure
{
  "success": false,
  "message": "Current password invalid"
}

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 to Server::Local if missing.
User Data Fetching
  • fetch_user_info(host: Server, auth: String) -> Option<User>
    Fetches user details from /users/me endpoint.
    Response format:
    {
      "user": {
        "uid": "user-id",
        "username": "john_doe",
        "email": "user@example.com",
        "is_active": true,
        "is_verified": true
      }
    }
    
Session Operations
  • refresh_user_token(req: &mut HttpReqCtx) -> Value
    Refreshes access token via /auth/refresh. Updates session token on success.
    Success response:
    { "success": true, "access_token": "new.jwt.token" }
    
  • 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()
    Adds Authorization: Bearer <token> header to requests.
  • get_user()
    Retrieves current user from request params (guest if unauthenticated).
  • auth_server_health()
    Checks /health endpoint (returns true for { "status": "ok" }).

Security Features

  1. HTTP-only Cookies
    Tokens are stored with http_only flag to prevent XSS access.
  2. Automatic Token Refresh
    /auth/refresh maintains session validity without re-login.
  3. Server-side Invalidation
    Tokens are disabled on logout via auth server.
  4. Guest Fallback
    Unauthenticated requests return User::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:

{
  "success": true,
  "total": 12,
  "users": [
    {
      "uid": 1,
      "username": "Admin",
      "email": "admin@example.com",
      "is_active": true,
      "is_admin": true
    }
  ]
}

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 identifier
  • email: Email address
  • password: Plaintext password (hashed server-side before storage)

Responses:

// Success (201 Created)
{ "success": true, "username": "alice" }

// 400 Bad Request — format validation failed
{ "success": false, "message": "Username is not valid" }

// 409 Conflict — value owned by another user
{ "success": false, "message": "Email already exists" }

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
{
  "success": true,
  "user": { "uid": 1, "username": "Admin", ... }
}

// 404 Not Found
{ "success": false, "message": "User 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 username
  • email: New email
  • is_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
{ "success": true }

// 404 Not Found / 400 Bad Request / 409 Conflict
{ "success": false, "message": "..." }

POST /admin/users/<uid>/password
Reset the user's password.
Parameters (URL-encoded form):

  • new_password: Plaintext password (hashed server-side)

Responses:

// 200 OK
{ "success": true }

// 400 Bad Request (empty password) / 404 Not Found
{ "success": false, "message": "..." }

POST /admin/users/<uid>/delete
Delete a user. Returns 404 if the uid doesn't exist.
Responses:

// 200 OK
{ "success": true }

// 404 Not Found
{ "success": false, "message": "User not found" }

3. Admin Membership API (JSON)

GET /admin/admins/json
List current admin entries.
Response:

{
  "success": true,
  "admins": ["1@local", "3@local"]
}

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 @local case.

Responses:

// 200 OK — idempotent: re-adding an existing entry succeeds silently
{ "success": true, "entry": "3@local" }

// 400 Bad Request — malformed or unknown uid
{ "success": false, "message": "Local user not found" }

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
{ "success": true }

// 400 Bad Request
{ "success": false, "message": "Invalid admin entry" }

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; None if missing.
  • admin_edit_user(uid, new_username, new_email, new_is_active) -> Result<(), FopError>
    Partial update with same-uid uniqueness exception. Holds username_map, email_map, users write 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 from users, username_map, and email_map atomically. Does not clean orphan admins.json entries (tracked in TICKETS.md).
UserStorage (in src/local_auth/fop.rs)

New field:

  • pub is_active: bool
    Defaults to true on 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 as 409 Conflict, distinct from the format- validation 400 produced by UserNameNotValid / 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 as Vec<String>.
  • add_admin_entry(entry: &str) -> std::io::Result<()>
    Idempotent. Persists to programfiles/admin_info/admins.json first, 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.

APIs