Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 0 additions & 25 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,6 @@

All notable changes to Sairo are documented here. This project uses [Semantic Versioning](https://semver.org/).

## [3.6.3] - 2026-07-13

### Fixed

- **The SPA shell is no longer heuristically cached by browsers.** `index.html` and `/manifest.json` are now served with `Cache-Control: no-cache, must-revalidate`, so a new deploy is picked up on the next load instead of users being stuck on a stale cached bundle (e.g. old branding/title) until a manual hard-refresh. Hashed `/assets/*` remain long-cacheable.

## [3.6.2] - 2026-07-02

White-label branding is now complete and seamless across every surface.

### Fixed

- **The app name follows `APP_NAME` everywhere.** Previously several surfaces showed "Sairo" regardless of `APP_NAME`: the browser tab title, the `og:title` used in link/social previews (e.g. Slack unfurls), the PWA manifest name, the first-run welcome modal, the update banner, and the public share-download page. The title/og/description/theme-color and the manifest are now injected **server-side**, so a branded deployment is correct the instant the page loads — no flash from "Sairo" while the client fetches branding.
- **`APP_LOGO` is now rendered** (login screen, app headers, and the share page) — it was previously fetched but never displayed.
- **`PRIMARY_COLOR` is now applied** to the UI accent (primary/hover/focus-ring and the tab theme-color) — previously read but never used.

## [3.6.1] - 2026-07-02

Fixes for the activation metric and white-label branding.

### Fixed

- **Activation instrumentation: `first_search_at` was under-counted.** It recorded only *after* the search endpoint's index-ready 503 gate, while its paired milestone `first_dashboard_open_at` recorded on handler entry — so searches issued during a fresh instance's initial crawl 503'd and never registered, skewing the activation funnel toward a false zero on exactly the new-install cohort. It now records on the search *request* (symmetric with dashboard-open); `first_search_returned_results` still records on the first served search.
- **Browser tab now follows `APP_NAME`.** White-label deployments that set `APP_NAME` showed the default "Sairo" in the browser tab title regardless; the document title now tracks the configured app name (falling back to "Sairo").

## [3.6.0] - 2026-06-29

Generic OpenID Connect SSO + a real per-bucket access UI (issue #9).
Expand Down
95 changes: 12 additions & 83 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from botocore.exceptions import ClientError
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Depends, Cookie, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse, JSONResponse, HTMLResponse
from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse, JSONResponse
import pyotp
from passlib.hash import bcrypt
from pydantic import BaseModel
Expand Down Expand Up @@ -294,7 +294,7 @@ async def s3_error_handler(request, exc):


_app_start_time = time.time()
SAIRO_VERSION = "3.6.3"
SAIRO_VERSION = "3.6.0"


def _version_gt(a: str, b: str) -> bool:
Expand Down Expand Up @@ -2502,24 +2502,17 @@ def _record_milestone_once(key: str):
pass


def _record_first_search():
"""Activation event: the user reached search. Recorded on the search REQUEST — before the
index-ready gate — so it is symmetric with first_dashboard_open_at (which records on view
open). Recording it only on a served 200 response systematically under-counted fresh
installs whose early searches 503 during the initial crawl, manufacturing a false 0%."""
_record_milestone_once("first_search_at")


def _record_search_returned(returned_results: bool):
"""Diagnostic paired with first_search_at: did the first *served* search return any results
(distinguishes a genuine no-match from the index-not-ready race). Set once, on the first
search that actually reaches the index."""
if "first_search_returned_results" in _recorded_milestones:
def _record_first_search(returned_results: bool):
"""Activation event: first search *served* (regardless of hit count). Also records whether
that first search returned any results — a free diagnostic for the index-not-ready race
(searched-but-zero-results before the crawl finished)."""
if "first_search_at" in _recorded_milestones:
return
try:
if _meta_get("first_search_returned_results") is None:
if not _meta_get("first_search_at"):
_meta_set("first_search_at", _iso_now())
_meta_set("first_search_returned_results", "1" if returned_results else "0")
_recorded_milestones.add("first_search_returned_results")
_recorded_milestones.add("first_search_at")
except Exception:
pass

Expand Down Expand Up @@ -4778,14 +4771,11 @@ def refresh_prefix(bucket: str, prefix: str = "", user: dict = Depends(require_a
@app.get("/api/buckets/{bucket}/search")
@limiter.limit("60/minute")
def search_objects(bucket: str, request: Request, q: str = Query(..., min_length=1), prefix: str = "", limit: int = 200, user: dict = Depends(get_current_user)):
_record_first_search() # activation: reached search — record on the request (symmetric with
# first_dashboard_open_at), BEFORE the index-ready gate, so a search
# issued during the initial crawl still counts.
if not _is_index_ready(bucket):
raise HTTPException(503, "Index not ready — crawl in progress")
with _get_db(bucket) as db:
rows = _search_fts(db, q, prefix, limit)
_record_search_returned(len(rows) > 0) # diagnostic, on the first served search
_record_first_search(len(rows) > 0) # activation milestone (fail-safe, idempotent)
return {"results": [dict(r) for r in rows], "count": len(rows), "query": q}


Expand Down Expand Up @@ -6889,76 +6879,15 @@ def bucket_info_compat(user: dict = Depends(get_current_user)):


# ── Serve React SPA ─────────────────────────────────────────────────────────
# ── White-label branding injection (pure, unit-testable) ─────────────────────

def _brand_name_color():
return (os.environ.get("APP_NAME", "Sairo"),
os.environ.get("PRIMARY_COLOR", "#3b82f6"))

def _apply_branding_html(doc: str, name: str, color: str) -> str:
"""Rewrite the brandable fields of index.html — tab title, og:title,
description, theme-color — from APP_NAME / PRIMARY_COLOR. Done server-side so
a white-label deployment is correct the instant the page loads (no client
flash from "Sairo", and correct og:title for link/social previews)."""
import re, html as _html
n = _html.escape(name, quote=True)
c = _html.escape(color, quote=True)
doc = re.sub(r"<title>.*?</title>", f"<title>{n}</title>", doc, count=1, flags=re.S)
doc = re.sub(r'(<meta property="og:title" content=")[^"]*(")', rf"\g<1>{n}\g<2>", doc)
doc = re.sub(r'(<meta name="description" content=")Sairo\b', rf"\g<1>{n}", doc)
doc = re.sub(r'(<meta name="theme-color" content=")[^"]*(")', rf"\g<1>{c}\g<2>", doc)
return doc

def _branded_manifest(m: dict, name: str, color: str) -> dict:
"""PWA manifest branded from APP_NAME / PRIMARY_COLOR."""
m = dict(m)
m["name"] = name
m["short_name"] = name
if color:
m["theme_color"] = color
return m


static_dir = os.path.join(os.path.dirname(__file__), "static")
if os.path.isdir(static_dir):
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")

_spa_cache: dict = {}

def _spa_index_html():
if "html" not in _spa_cache:
try:
with open(os.path.join(static_dir, "index.html"), encoding="utf-8") as f:
_spa_cache["html"] = _apply_branding_html(f.read(), *_brand_name_color())
except Exception:
_spa_cache["html"] = None
return _spa_cache["html"]

# The SPA shell + manifest must revalidate on every load, or browsers
# heuristically cache them and users stay on a stale bundle after a deploy
# (e.g. old branding/title until a manual hard-refresh). Hashed /assets/*
# remain long-cacheable — only the entry documents are no-cache.
_NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"}

@app.get("/manifest.json")
def serve_manifest():
try:
with open(os.path.join(static_dir, "manifest.json"), encoding="utf-8") as f:
m = json.load(f)
except Exception:
m = {"start_url": "/", "display": "standalone"}
return JSONResponse(_branded_manifest(m, *_brand_name_color()), headers=_NO_CACHE)

@app.get("/{path:path}")
def serve_spa(path: str):
file_path = os.path.realpath(os.path.join(static_dir, path))
if not file_path.startswith(os.path.realpath(static_dir)):
raise HTTPException(403, "Forbidden")
if os.path.isfile(file_path):
return FileResponse(file_path)
# SPA entry: serve index.html with the app name/colour baked in, and
# never let the browser serve a stale shell after a deploy.
doc = _spa_index_html()
if doc is not None:
return HTMLResponse(doc, headers=_NO_CACHE)
return FileResponse(os.path.join(static_dir, "index.html"), headers=_NO_CACHE)
return FileResponse(os.path.join(static_dir, "index.html"))
67 changes: 0 additions & 67 deletions backend/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,73 +646,6 @@ def test_users_list_includes_auth_source_and_bucket_count(self, client, admin_co
assert "bucket_count" in admin and isinstance(admin["bucket_count"], int)


class TestActivationMilestones:
"""3.6.1 fix: first_search_at must record on the search REQUEST — symmetric with
first_dashboard_open_at — even when the index isn't ready and the search 503s. The old
placement (after the 503 gate) under-counted fresh installs and manufactured a false 0%."""

def test_first_search_records_even_when_index_not_ready(self, app, client, admin_cookies):
m = _main_module()
# reset any prior recording so we observe this request's effect
m._recorded_milestones.discard("first_search_at")
with m._get_users_db() as db:
db.execute("DELETE FROM instance_meta WHERE key='first_search_at'")
db.commit()
# a bucket with no index → the search 503s (index not ready) ...
resp = client.get("/api/buckets/unindexed-bucket/search",
params={"q": "hello"}, cookies=admin_cookies)
assert resp.status_code == 503
# ... but the activation milestone must still have recorded (it fires before the gate)
assert m._meta_get("first_search_at") is not None, \
"first_search_at must record even when the search 503s during indexing"


class TestBrandingInjection:
"""3.6.2 white-label: APP_NAME / PRIMARY_COLOR are injected into the served
HTML + manifest server-side, so a branded deployment never leaks "Sairo"
(tab title, og:title for link previews, PWA name) and shows no load flash."""

SAMPLE_HTML = (
'<title>Sairo</title>\n'
'<meta property="og:title" content="Sairo" />\n'
'<meta name="description" content="Sairo — S3-compatible object storage browser" />\n'
'<meta name="theme-color" content="#3b82f6" />'
)

def test_html_fields_follow_app_name_and_color(self):
m = _main_module()
out = m._apply_branding_html(self.SAMPLE_HTML, "Objex", "#e11d48")
assert "<title>Objex</title>" in out
assert 'property="og:title" content="Objex"' in out # link/social previews
assert 'content="Objex — S3-compatible' in out # description
assert 'name="theme-color" content="#e11d48"' in out # tab/PWA colour
assert "Sairo" not in out # no leak anywhere

def test_html_default_keeps_sairo(self):
m = _main_module()
out = m._apply_branding_html(self.SAMPLE_HTML, "Sairo", "#3b82f6")
assert "<title>Sairo</title>" in out # vanilla install unchanged

def test_html_escapes_app_name(self):
m = _main_module()
out = m._apply_branding_html("<title>Sairo</title>", "A&B<x>", "#000")
assert "A&amp;B&lt;x&gt;" in out and "<title>A&B<x></title>" not in out

def test_manifest_branded(self):
m = _main_module()
out = m._branded_manifest(
{"name": "Sairo", "short_name": "Sairo", "theme_color": "#3b82f6", "start_url": "/"},
"Objex", "#e11d48")
assert out["name"] == "Objex" and out["short_name"] == "Objex"
assert out["theme_color"] == "#e11d48"
assert out["start_url"] == "/" # untouched fields preserved

def test_manifest_default_keeps_sairo(self):
m = _main_module()
out = m._branded_manifest({"name": "Sairo", "short_name": "Sairo"}, "Sairo", "#3b82f6")
assert out["name"] == "Sairo"


# ── Health Check ─────────────────────────────────────────

class TestHealth:
Expand Down
4 changes: 2 additions & 2 deletions charts/sairo/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ apiVersion: v2
name: sairo-helm
description: S3-compatible object storage browser with search, versioning, and analytics
type: application
version: 1.4.3
appVersion: "3.6.3"
version: 1.4.0
appVersion: "3.6.0"
keywords:
- s3
- object-storage
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sairo",
"version": "3.6.3",
"version": "3.6.0",
"description": "S3-compatible object storage browser with search, versioning, and analytics",
"private": true,
"type": "module",
Expand Down
40 changes: 8 additions & 32 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,26 +167,6 @@ function MainApp() {
getBranding().then(setBranding).catch(() => {});
}, []);

// Keep the browser tab title in sync with the (white-label) app name, so a
// deployment that sets APP_NAME doesn't still read "Sairo" in the tab.
useEffect(() => {
if (branding.app_name) document.title = branding.app_name;
}, [branding.app_name]);

// Apply a white-label PRIMARY_COLOR to the accent CSS variables (main accent,
// hover, focus ring) so a branded deployment is recoloured throughout the UI.
useEffect(() => {
const hex = branding.primary_color;
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
if (!m) return;
const [r, g, b] = [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16));
const dark = (v, f) => Math.max(0, Math.round(v * (1 - f)));
const root = document.documentElement.style;
root.setProperty("--primary", `#${m[1]}`);
root.setProperty("--primary-hover", `rgb(${dark(r, .12)}, ${dark(g, .12)}, ${dark(b, .12)})`);
root.setProperty("--primary-ring", `rgba(${r}, ${g}, ${b}, 0.3)`);
}, [branding.primary_color]);

// Listen for session-expired events from api.js (replaces hard page reload)
useEffect(() => {
const handler = () => {
Expand Down Expand Up @@ -618,14 +598,6 @@ function MainApp() {
}

const appName = branding.app_name || "Sairo";
// White-label logo: use APP_LOGO when set, else the default mark.
const logoMark = branding.app_logo
? <img src={branding.app_logo} alt={appName} style={{ height: 22, width: "auto", display: "block" }} />
: (
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="22" height="22">
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
</svg>
);

const userBadge = (
<div className="user-badge">
Expand All @@ -643,7 +615,9 @@ function MainApp() {
<header>
<div className="header-left">
<span className="header-logo-mark">
{logoMark}
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="22" height="22">
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
</svg>
</span>
<h1>{appName}</h1>
<span className="bucket-name">Object Storage</span>
Expand All @@ -667,7 +641,7 @@ function MainApp() {
</span>
<div className="ub-body">
<div className="ub-head">
{appName} <strong>v{updateInfo.latest}</strong> is here <span className="ub-cur">· you're on v{updateInfo.current}</span>
Sairo <strong>v{updateInfo.latest}</strong> is here <span className="ub-cur">· you're on v{updateInfo.current}</span>
</div>
<div className="ub-chips">
{UPDATE_HIGHLIGHTS.map((h) => <span className="ub-chip" key={h}>{h}</span>)}
Expand All @@ -692,7 +666,7 @@ function MainApp() {
<BucketList onSelect={navigateBucket} role={user.role} onDashboard={setDashboardBucket} />
{showAuditLog && <AuditLog onClose={() => setShowAuditLog(false)} />}
{dashboardBucket && <StorageDashboard bucket={dashboardBucket} onClose={() => setDashboardBucket(null)} onNavigate={(pfx) => { setDashboardBucket(null); setHash(dashboardBucket, pfx); }} />}
{showWelcome && <Welcome onDismiss={() => setShowWelcome(false)} appName={appName} />}
{showWelcome && <Welcome onDismiss={() => setShowWelcome(false)} />}
{showTokenManager && <TokenManager onClose={() => setShowTokenManager(false)} />}
{showLicense && <LicenseManager onClose={() => setShowLicense(false)} />}
{showUserManager && <UserManager onClose={() => setShowUserManager(false)} currentUser={user} />}
Expand All @@ -716,7 +690,9 @@ function MainApp() {
<header>
<div className="header-left">
<span className="header-logo-mark" style={{ cursor: "pointer" }} onClick={goHome}>
{logoMark}
<svg viewBox="0 0 40 40" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" width="20" height="20">
<path d="M28 10c0 3-4 5.5-8 5.5S12 13 12 10s4-5.5 8-5.5 8 2.5 8 5.5z"/><path d="M28 20c0 3-4 5.5-8 5.5S12 23 12 20"/><path d="M28 30c0 3-4 5.5-8 5.5S12 33 12 30"/><line x1="12" y1="10" x2="12" y2="30"/><line x1="28" y1="10" x2="28" y2="30"/>
</svg>
</span>
<h1 style={{ cursor: "pointer" }} onClick={goHome}>{appName}</h1>
<span className="bucket-name">{bucket}</span>
Expand Down
Loading
Loading