Merge R4: auth funnel & abuse hardening
S1 dead ends (password-reset verifies email, resend-verification endpoint+CTA, OAuth-cancel redirect + marker pop before exchange) · dev OAuth returns to the origin that started it · S2 E2EE forgotten-passphrase reset (key-reset endpoint, KeyGate danger-confirm, partner-key refresh on decrypt failure) · S3 abuse & leak hardening (password_version share grants [migration 0059], _messageable filters on share endpoints, GET /keys MB2 anti-enum, register IntegrityError no-op, atomic scheduler single-run claim) · share-link password change/clear UI.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
"""add password_version to download_links (invalidate watch grants on password change)
|
||||
|
||||
Revision ID: 0059_dl_link_pw_version
|
||||
Revises: 0058_dl_default_720p
|
||||
Create Date: 2026-07-23
|
||||
|
||||
A password-protected share link mints a short-lived HMAC "grant" at /unlock so the media URL never
|
||||
carries the password. The grant previously signed only <token>.<exp>, so rotating a link's password
|
||||
did NOT invalidate outstanding grants — they lived out their 6h TTL. Fold a per-link password_version
|
||||
into the signed grant and bump it on every password change, so a rotation revokes old grants at once.
|
||||
Small table (one row per share link), so this is a quick add-column with a default.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0059_dl_link_pw_version"
|
||||
down_revision: Union[str, None] = "0058_dl_default_720p"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"download_links",
|
||||
sa.Column("password_version", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("download_links", "password_version")
|
||||
+108
-14
@@ -9,6 +9,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from starlette.requests import HTTPConnection
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import email as email_mod
|
||||
@@ -31,6 +32,7 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
|
||||
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||
_reset_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
# At most one "your account is suspended" email per address per hour, so a suspended user who
|
||||
# keeps retrying (or a script with their valid credentials) can't be used to mailbomb them.
|
||||
@@ -245,6 +247,34 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None:
|
||||
return inv
|
||||
|
||||
|
||||
# Local-dev origins allowed to drive the OAuth redirect_uri from the request Host. The Vite dev
|
||||
# server (:5173) proxies /auth to the backend (:8080) preserving the Host header, and the session
|
||||
# cookie is port-agnostic — so a flow started on :5173 can come back to :5173 instead of always
|
||||
# landing on the fixed :8080 callback. Deriving the redirect_uri from the request host keeps the
|
||||
# WHOLE round-trip on ONE origin (login, Google callback, and the session/state cookie all on the
|
||||
# same host) — that consistency is load-bearing, because a session cookie set on 127.0.0.1 is NOT
|
||||
# sent to localhost and vice versa, so a cross-host fallback would silently lose the OAuth state.
|
||||
# EVERY host listed here must ALSO be registered as a redirect URI in the Google console, or Google
|
||||
# rejects it with redirect_uri_mismatch. An unlisted host falls back to the fixed configured
|
||||
# callback (fine only when that lands on the same host the flow started on).
|
||||
_DEV_OAUTH_HOSTS = frozenset(
|
||||
{"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"}
|
||||
)
|
||||
|
||||
|
||||
def _oauth_redirect_uri(request: Request) -> str:
|
||||
"""The redirect_uri handed to Google. Production always uses the fixed configured URL and never
|
||||
trusts the request Host (a forged Host header must not be able to redirect the OAuth code). In
|
||||
local dev we honor the request's own origin when it is one of the known dev hosts, so a login
|
||||
started on the Vite server (:5173) returns to :5173 and one on :8080 returns to :8080."""
|
||||
if settings.session_https_only: # https redirect URL == real deployment
|
||||
return settings.oauth_redirect_url
|
||||
host = (request.headers.get("host") or "").lower()
|
||||
if host in _DEV_OAUTH_HOSTS:
|
||||
return f"http://{host}/auth/callback"
|
||||
return settings.oauth_redirect_url
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, db: Session = Depends(get_db)):
|
||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
||||
@@ -257,7 +287,7 @@ async def login(request: Request, db: Session = Depends(get_db)):
|
||||
return RedirectResponse(url="/")
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
settings.oauth_redirect_url,
|
||||
_oauth_redirect_uri(request),
|
||||
access_type="offline",
|
||||
prompt="select_account",
|
||||
include_granted_scopes="true",
|
||||
@@ -274,10 +304,27 @@ async def callback(
|
||||
client = google_oauth(db)
|
||||
if client is None:
|
||||
return RedirectResponse(url="/")
|
||||
# Linking mode: an already-authenticated account is attaching this Google identity (a
|
||||
# password account enabling YouTube or connecting SSO). The markers are set by /auth/link and
|
||||
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
|
||||
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
|
||||
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
|
||||
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
|
||||
link_uid = request.session.pop("oauth_link_uid", None)
|
||||
link_explicit = request.session.pop("oauth_link_explicit", False)
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}")
|
||||
# The user cancelled or denied consent at Google — land back with a friendly notice instead
|
||||
# of a raw 400 JSON error page. An already-signed-in user (adding another account via
|
||||
# /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login
|
||||
# banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on
|
||||
# the login page with the oauth_cancelled banner instead. Keyed on the live session, not on
|
||||
# link_uid, so the add-account path (no marker) is covered too.
|
||||
log.info("Google OAuth cancelled/failed: %s", exc.error)
|
||||
if request.session.get("user_id") is not None:
|
||||
return RedirectResponse(url="/?oauth=cancelled")
|
||||
return RedirectResponse(url="/?login=oauth_cancelled")
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not userinfo or not userinfo.get("sub"):
|
||||
@@ -286,12 +333,8 @@ async def callback(
|
||||
sub = userinfo["sub"]
|
||||
email = (userinfo.get("email") or "").lower()
|
||||
|
||||
# Linking mode: an already-authenticated account is attaching this Google identity (a
|
||||
# password account enabling YouTube or connecting SSO). The marker is set by /auth/link and
|
||||
# /auth/upgrade; when present we attach to the current user instead of identifying by sub.
|
||||
link_uid = request.session.pop("oauth_link_uid", None)
|
||||
if link_uid is not None:
|
||||
return _complete_link(request, db, link_uid, sub, userinfo, token)
|
||||
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
|
||||
|
||||
if not is_allowed(db, email):
|
||||
log.warning("Login denied (not approved): %s", email or "<no email>")
|
||||
@@ -456,13 +499,19 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
|
||||
|
||||
|
||||
def _complete_link(
|
||||
request: Request, db: Session, link_uid: int, sub: str, userinfo: dict, token: dict
|
||||
request: Request,
|
||||
db: Session,
|
||||
link_uid: int,
|
||||
explicit: bool,
|
||||
sub: str,
|
||||
userinfo: dict,
|
||||
token: dict,
|
||||
):
|
||||
"""Attach the just-authorized Google identity to the already-signed-in account that started
|
||||
the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a
|
||||
Google identity owned by another account, or to silently swap a different linked identity."""
|
||||
# Consume the companion marker up front so it never lingers across the early returns below.
|
||||
explicit = request.session.pop("oauth_link_explicit", False)
|
||||
Google identity owned by another account, or to silently swap a different linked identity.
|
||||
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
||||
a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind."""
|
||||
# The marker must match the live session user (guards a stale/forged marker).
|
||||
if link_uid != request.session.get("user_id"):
|
||||
return RedirectResponse(url="/?link=error")
|
||||
@@ -670,7 +719,14 @@ def _register_account(email: str, password: str) -> None:
|
||||
email_verified=not email_ok,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
try:
|
||||
db.flush()
|
||||
except IntegrityError:
|
||||
# A concurrent registration for the same new email won the race between the pre-check
|
||||
# above and this flush (the unique-email constraint fires). Same already-registered
|
||||
# no-op as the pre-check hit — roll back and stop.
|
||||
db.rollback()
|
||||
return
|
||||
upsert_pending_invite(db, email) # admin-approval gate (commits)
|
||||
if email_ok:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
@@ -693,6 +749,39 @@ def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/verify/resend")
|
||||
def verify_email_resend(
|
||||
payload: dict,
|
||||
request: Request,
|
||||
background: BackgroundTasks,
|
||||
) -> dict:
|
||||
"""Re-issue an email-verification link. Needed because the original link expires (VERIFY_TTL)
|
||||
and re-registering an already-registered email is a silent no-op, so a user who lost the first
|
||||
mail had no way back. Uniform response + timing regardless of whether the email has a pending
|
||||
account (the lookup runs off the response path), so it can't probe for registered emails."""
|
||||
if not _verify_resend_limiter.allow(_client_ip(request)):
|
||||
return {"status": "ok"} # silently throttle; uniform response
|
||||
email = (payload.get("email") or "").strip().lower()
|
||||
if valid_email(email):
|
||||
background.add_task(_resend_verify_if_eligible, email)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _resend_verify_if_eligible(email: str) -> None:
|
||||
"""Background worker for verify/resend: issue a fresh verify token + email the link, but ONLY for
|
||||
a real account that still needs verifying. Runs after the response with its own DB session, so the
|
||||
request's timing never reveals whether the account exists. No-op when SMTP is disabled (accounts
|
||||
are auto-verified on a no-SMTP instance, so there is nothing to resend). Token rides the URL
|
||||
FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3)."""
|
||||
if not email_mod.email_enabled():
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
|
||||
if user is not None and not user.email_verified and not user.is_demo:
|
||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
||||
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||
"""Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the
|
||||
@@ -787,6 +876,11 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||
user.password_hash = hash_password(new_password)
|
||||
# A valid reset link proves control of the mailbox, so completing a reset also verifies the
|
||||
# email — this rescues an account that registered but never clicked the verification link (it
|
||||
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
|
||||
# here: a reset proves email ownership, not that an admin approved the account.
|
||||
user.email_verified = True
|
||||
bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||
# Burn any other outstanding reset tokens for this user.
|
||||
for row in db.execute(
|
||||
@@ -951,7 +1045,7 @@ async def link_google(
|
||||
request.session["oauth_link_explicit"] = True
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
settings.oauth_redirect_url,
|
||||
_oauth_redirect_uri(request),
|
||||
access_type="offline",
|
||||
prompt="select_account",
|
||||
include_granted_scopes="true",
|
||||
@@ -1016,7 +1110,7 @@ async def upgrade(
|
||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||
return await client.authorize_redirect(
|
||||
request,
|
||||
settings.oauth_redirect_url,
|
||||
_oauth_redirect_uri(request),
|
||||
access_type="offline",
|
||||
prompt="consent",
|
||||
include_granted_scopes="true",
|
||||
|
||||
@@ -33,13 +33,17 @@ def _sign(msg: str) -> str:
|
||||
).hexdigest()[:32]
|
||||
|
||||
|
||||
def make_grant(token: str, now: float | None = None) -> str:
|
||||
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`."""
|
||||
def make_grant(token: str, password_version: int, now: float | None = None) -> str:
|
||||
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`. The
|
||||
link's current `password_version` is folded into the signature, so rotating the password (which
|
||||
bumps the version) invalidates every grant minted under the old password at once."""
|
||||
exp = int((now if now is not None else datetime.now(timezone.utc).timestamp())) + _GRANT_TTL
|
||||
return f"{exp}.{_sign(f'{token}.{exp}')}"
|
||||
return f"{exp}.{_sign(f'{token}.{exp}.{password_version}')}"
|
||||
|
||||
|
||||
def check_grant(token: str, grant: str | None) -> bool:
|
||||
def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
||||
"""Verify a grant against the link's CURRENT `password_version`; a version bump (password
|
||||
rotation) fails the signature check even before the grant's TTL expires."""
|
||||
if not grant or "." not in grant:
|
||||
return False
|
||||
exp_s, sig = grant.split(".", 1)
|
||||
@@ -49,7 +53,7 @@ def check_grant(token: str, grant: str | None) -> bool:
|
||||
return False
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
return False
|
||||
return hmac.compare_digest(sig, _sign(f"{token}.{exp}"))
|
||||
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
|
||||
|
||||
|
||||
# --- serialization -------------------------------------------------------------------------
|
||||
|
||||
@@ -943,6 +943,10 @@ class DownloadLink(Base, TimestampMixin):
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
||||
# Bumped whenever the password changes; folded into the signed watch grant so rotating the
|
||||
# password immediately invalidates any grants issued under the old one (instead of them living
|
||||
# out the 6h grant TTL).
|
||||
password_version: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.security import hash_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
|
||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
||||
@@ -666,7 +667,9 @@ def share_download(
|
||||
recipient = db.execute(
|
||||
select(User).where(func.lower(User.email) == target)
|
||||
).scalar_one_or_none()
|
||||
if recipient is None:
|
||||
# Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so
|
||||
# sharing can't be used to probe which addresses exist or to reach a suspended account.
|
||||
if not is_messageable_user(recipient):
|
||||
raise HTTPException(status_code=404, detail="No user with that email.")
|
||||
if recipient.id == user.id:
|
||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
||||
@@ -741,11 +744,12 @@ def remove_shared_with_me(
|
||||
def share_recipients(
|
||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
"""Registered users this user can share a download with (excludes self + the demo account)."""
|
||||
"""Registered users this user can share a download with (excludes self + any non-messageable
|
||||
account — demo, suspended, or deactivated — so the picker never leaks those addresses)."""
|
||||
rows = (
|
||||
db.execute(
|
||||
select(User)
|
||||
.where(User.id != user.id, User.is_demo.is_(False))
|
||||
.where(User.id != user.id, *messageable_clauses())
|
||||
.order_by(func.lower(User.email))
|
||||
)
|
||||
.scalars()
|
||||
@@ -834,6 +838,9 @@ def update_link(
|
||||
if "password" in payload:
|
||||
pw = (payload.get("password") or "").strip()
|
||||
link.password_hash = hash_password(pw) if pw else None
|
||||
# Bump the version so any grant issued under the previous password stops working now, rather
|
||||
# than living out its 6h TTL (setting/clearing/changing the password all count as a rotation).
|
||||
link.password_version += 1
|
||||
db.commit()
|
||||
return linksmod.owner_view(link)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy import and_, case, delete, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_human, resolved_user_id
|
||||
@@ -25,6 +25,8 @@ from app.db import SessionLocal, get_db
|
||||
from app.models import Message, MessageKey, User
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.realtime import manager
|
||||
from app.security import verify_password
|
||||
from app.userscope import is_messageable_user, messageable_clauses
|
||||
|
||||
router = APIRouter(prefix="/api/messages", tags=["messages"])
|
||||
|
||||
@@ -32,6 +34,7 @@ MAX_CIPHERTEXT = 8000 # base64 of a generous plaintext + AES-GCM overhead
|
||||
SYSTEM_PARTNER_ID = 0 # synthetic conversation partner for system messages
|
||||
SYSTEM_NAME = "Siftlode"
|
||||
_send_limiter = RateLimiter(max_events=30, window_seconds=60)
|
||||
_reset_key_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||
|
||||
# The welcome is rendered server-side at creation time, so it carries its own translations
|
||||
# (one per supported UI language). Kept short and non-private (every new user gets the same text).
|
||||
@@ -62,6 +65,11 @@ class KeySetupIn(BaseModel):
|
||||
key_check: str
|
||||
|
||||
|
||||
class KeyResetIn(BaseModel):
|
||||
# Present only for password accounts; a Google-only account has no password to re-verify.
|
||||
current_password: str | None = None
|
||||
|
||||
|
||||
class SendIn(BaseModel):
|
||||
recipient_id: int
|
||||
ciphertext: str
|
||||
@@ -94,18 +102,6 @@ def _serialize_msg(m: Message) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _messageable() -> list:
|
||||
"""SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries."""
|
||||
return [
|
||||
User.is_demo.is_(False),
|
||||
User.is_active.is_(True),
|
||||
User.is_suspended.is_(False),
|
||||
]
|
||||
|
||||
|
||||
def is_messageable_user(u: User | None) -> bool:
|
||||
"""Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient)."""
|
||||
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
|
||||
|
||||
|
||||
def ensure_welcome(db: Session, user: User) -> None:
|
||||
@@ -172,11 +168,63 @@ def setup_keys(
|
||||
return {"configured": True}
|
||||
|
||||
|
||||
@router.post("/keys/reset")
|
||||
def reset_keys(
|
||||
payload: KeyResetIn,
|
||||
user: User = Depends(require_human),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Forgotten-passphrase escape hatch: drop this user's secure-messaging key so they can set a
|
||||
NEW passphrase (the set-once guard in setup_keys otherwise makes a lost passphrase a permanent
|
||||
wall). Destroys the user's entire E2EE history BY DESIGN — every conversation key is derived
|
||||
from this keypair, so replacing it orphans every stored ciphertext in both directions. Those
|
||||
now-undecryptable `user` messages are deleted here (a clean slate for both parties) rather than
|
||||
left as permanent "can't decrypt" clutter; `system` messages (plaintext) are untouched.
|
||||
|
||||
An account WITH a password must re-authenticate: a hijacked session must not be able to silently
|
||||
reset messaging and then read the victim's future messages under a new passphrase. A password-
|
||||
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
|
||||
client-side danger confirmation."""
|
||||
if not _reset_key_limiter.allow(str(user.id)):
|
||||
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.")
|
||||
if user.password_hash and not verify_password(
|
||||
payload.current_password or "", user.password_hash
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
||||
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
|
||||
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
|
||||
db.execute(
|
||||
delete(Message).where(
|
||||
Message.kind == "user",
|
||||
or_(Message.sender_id == user.id, Message.recipient_id == user.id),
|
||||
)
|
||||
)
|
||||
key = db.get(MessageKey, user.id)
|
||||
if key is not None:
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
return {"configured": False}
|
||||
|
||||
|
||||
@router.get("/keys/{user_id}")
|
||||
def public_key(
|
||||
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""A member's public key, for deriving the pairwise conversation key."""
|
||||
"""A member's public key, for deriving the pairwise conversation key. MB2 (like get_thread):
|
||||
resolve the target only if they're messageable OR we already share a thread, else the same
|
||||
"User not found" as a non-existent id — so this can't be probed to enumerate user_ids or to
|
||||
fetch keys for suspended/deactivated/demo accounts."""
|
||||
cond = and_(
|
||||
Message.kind == "user",
|
||||
or_(
|
||||
and_(Message.sender_id == user.id, Message.recipient_id == user_id),
|
||||
and_(Message.sender_id == user_id, Message.recipient_id == user.id),
|
||||
),
|
||||
)
|
||||
p = db.get(User, user_id)
|
||||
has_history = db.scalar(select(Message.id).where(cond).limit(1))
|
||||
if p is None or (not is_messageable_user(p) and not has_history):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
k = db.get(MessageKey, user_id)
|
||||
if k is None:
|
||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||
@@ -195,7 +243,7 @@ def list_recipients(
|
||||
db.execute(
|
||||
select(User)
|
||||
.join(MessageKey, MessageKey.user_id == User.id)
|
||||
.where(User.id != user.id, *_messageable())
|
||||
.where(User.id != user.id, *messageable_clauses())
|
||||
.order_by(func.lower(func.coalesce(User.display_name, User.email)))
|
||||
)
|
||||
.scalars()
|
||||
|
||||
@@ -97,7 +97,7 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
|
||||
job, asset = _ready_target(db, link)
|
||||
link.view_count += 1
|
||||
db.commit()
|
||||
grant = linksmod.make_grant(token)
|
||||
grant = linksmod.make_grant(token, link.password_version)
|
||||
return {
|
||||
"needs_password": False,
|
||||
**linksmod.public_meta(
|
||||
@@ -111,7 +111,7 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)):
|
||||
"""Range-aware stream of the shared file. Inline by default (plays in the page); an
|
||||
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g):
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
asset = _ready_asset(db, link)
|
||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
||||
@@ -129,7 +129,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db)
|
||||
"""The generated poster frame for a shared thumbnail-less video (used as the page/video poster
|
||||
and the link-preview image). Password links require a valid grant, like the file."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash and not linksmod.check_grant(token, g):
|
||||
if link.password_hash and not linksmod.check_grant(token, g, link.password_version):
|
||||
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||
job, asset = _ready_target(db, link)
|
||||
if not asset.poster_path:
|
||||
|
||||
@@ -162,9 +162,15 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
|
||||
|
||||
|
||||
def _job(name: str, fn) -> None:
|
||||
# The one place both run paths converge (manual trigger AND scheduled fire), so claim the run
|
||||
# HERE, atomically: whichever reaches this first wins, the other skips. This is the real
|
||||
# single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI.
|
||||
if not _claim_running(name):
|
||||
logger.info("job %s already running — skipping this trigger", name)
|
||||
return
|
||||
db = SessionLocal()
|
||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
||||
_record(name, running=True, last_started=_now_iso(), last_error=None, progress=None)
|
||||
_record(name, last_started=_now_iso(), last_error=None, progress=None) # running already claimed
|
||||
|
||||
def sink(current, total, phase):
|
||||
_record(name, progress={"current": current, "total": total, "phase": phase})
|
||||
@@ -172,7 +178,7 @@ def _job(name: str, fn) -> None:
|
||||
try:
|
||||
if is_sync_paused(db):
|
||||
logger.info("job %s skipped (sync paused)", name)
|
||||
_record(name, running=False, status="skipped", last_finished=_now_iso(),
|
||||
_record(name, status="skipped", last_finished=_now_iso(),
|
||||
last_result="paused", progress=None)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
||||
@@ -182,7 +188,7 @@ def _job(name: str, fn) -> None:
|
||||
result = fn(db)
|
||||
summary = _summarize(result)
|
||||
logger.info("job %s -> %s", name, result)
|
||||
_record(name, running=False, status="ok", last_finished=_now_iso(),
|
||||
_record(name, status="ok", last_finished=_now_iso(),
|
||||
last_result=summary, progress=None)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "ok", summary)
|
||||
@@ -191,12 +197,15 @@ def _job(name: str, fn) -> None:
|
||||
db.rollback()
|
||||
logger.exception("job %s failed", name)
|
||||
err = str(exc) or exc.__class__.__name__
|
||||
_record(name, running=False, status="error", last_finished=_now_iso(),
|
||||
_record(name, status="error", last_finished=_now_iso(),
|
||||
last_error=err, progress=None)
|
||||
if actor_id is not None:
|
||||
_notify_done(db, actor_id, name, "error", err)
|
||||
_audit_run(db, actor_id, name, "error", err, None)
|
||||
finally:
|
||||
# ALWAYS release the claim, even if the body raised before its own status update — so a
|
||||
# failure can never wedge the job as permanently "running".
|
||||
_record(name, running=False)
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -380,6 +389,18 @@ def _is_running(job_id: str) -> bool:
|
||||
return bool(_activity.get(job_id, {}).get("running"))
|
||||
|
||||
|
||||
def _claim_running(job_id: str) -> bool:
|
||||
"""Atomically claim a job as running: True if it was idle (and marks it running now), False if
|
||||
it was already running. Holding the lock across BOTH the check and the set is what makes the
|
||||
single-run guard race-free — called once at the top of _job so a manual trigger and a scheduled
|
||||
fire (or two rapid triggers) converge here and exactly one wins. Released in _job's finally."""
|
||||
with _activity_lock:
|
||||
if _activity.get(job_id, {}).get("running"):
|
||||
return False
|
||||
_activity.setdefault(job_id, {})["running"] = True
|
||||
return True
|
||||
|
||||
|
||||
def running_job_ids() -> set[str]:
|
||||
"""Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
|
||||
header's sync indicator — reflect real activity instead of just pending-work counts."""
|
||||
@@ -399,6 +420,9 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str:
|
||||
fn = JOB_FUNCS.get(job_id)
|
||||
if fn is None:
|
||||
raise KeyError(job_id)
|
||||
# Best-effort fast answer for the UI; the authoritative single-run guard is _job's atomic claim
|
||||
# (so a manual trigger and a scheduled fire can't both run). A race here just means the loser's
|
||||
# _job claims, sees it's taken, and skips — no double run.
|
||||
if _is_running(job_id):
|
||||
return "already_running"
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Shared "who is a real, active, non-suspended human" predicate.
|
||||
|
||||
Used wherever a user must be filtered out of a directory / recipient list or rejected as a target:
|
||||
the messaging directory AND the download share-by-email / recipient-picker endpoints. Kept in one
|
||||
place so the two forms (SQL clauses for a query, Python check for a loaded row) can't drift apart,
|
||||
and so a route module doesn't have to import another route module just to reuse the rule.
|
||||
"""
|
||||
from app.models import User
|
||||
|
||||
|
||||
def messageable_clauses() -> list:
|
||||
"""SQL clauses for "a real, active, non-suspended human" — splat into a directory/recipient query."""
|
||||
return [
|
||||
User.is_demo.is_(False),
|
||||
User.is_active.is_(True),
|
||||
User.is_suspended.is_(False),
|
||||
]
|
||||
|
||||
|
||||
def is_messageable_user(u: User | None) -> bool:
|
||||
"""Python mirror of `messageable_clauses()` for an already-loaded row (WS auth, send recipient)."""
|
||||
return bool(u and not u.is_demo and u.is_active and not u.is_suspended)
|
||||
+17
-10
@@ -14,30 +14,37 @@ def test_new_token_is_unguessable_and_unique():
|
||||
|
||||
class TestGrant:
|
||||
def test_a_fresh_grant_verifies_for_its_own_token(self):
|
||||
grant = make_grant("tok")
|
||||
assert check_grant("tok", grant) is True
|
||||
grant = make_grant("tok", 0)
|
||||
assert check_grant("tok", grant, 0) is True
|
||||
|
||||
def test_a_grant_does_not_verify_for_a_different_token(self):
|
||||
# The signature binds the token, so a grant minted for one link can't unlock another.
|
||||
assert check_grant("other", make_grant("tok")) is False
|
||||
assert check_grant("other", make_grant("tok", 0), 0) is False
|
||||
|
||||
def test_a_tampered_signature_is_rejected(self):
|
||||
exp, _sig = make_grant("tok").split(".", 1)
|
||||
assert check_grant("tok", f"{exp}.deadbeef") is False
|
||||
exp, _sig = make_grant("tok", 0).split(".", 1)
|
||||
assert check_grant("tok", f"{exp}.deadbeef", 0) is False
|
||||
|
||||
def test_a_tampered_expiry_is_rejected(self):
|
||||
# Extending the exp field invalidates the signature (which covers token.exp).
|
||||
_exp, sig = make_grant("tok").split(".", 1)
|
||||
# Extending the exp field invalidates the signature (which covers token.exp.version).
|
||||
_exp, sig = make_grant("tok", 0).split(".", 1)
|
||||
future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL
|
||||
assert check_grant("tok", f"{future}.{sig}") is False
|
||||
assert check_grant("tok", f"{future}.{sig}", 0) is False
|
||||
|
||||
def test_an_expired_grant_is_rejected(self):
|
||||
past = datetime.now(timezone.utc).timestamp() - 2 * _GRANT_TTL
|
||||
assert check_grant("tok", make_grant("tok", now=past)) is False
|
||||
assert check_grant("tok", make_grant("tok", 0, now=past), 0) is False
|
||||
|
||||
def test_a_password_rotation_invalidates_an_old_grant(self):
|
||||
# A grant minted at version 0 must fail once the link's password_version has been bumped —
|
||||
# this is what makes changing a link password revoke outstanding grants immediately.
|
||||
grant = make_grant("tok", 0)
|
||||
assert check_grant("tok", grant, 0) is True
|
||||
assert check_grant("tok", grant, 1) is False
|
||||
|
||||
def test_malformed_grants_are_rejected_not_crashed(self):
|
||||
for bad in [None, "", "no-dot", "notanumber.sig"]:
|
||||
assert check_grant("tok", bad) is False
|
||||
assert check_grant("tok", bad, 0) is False
|
||||
|
||||
|
||||
class TestIsExpired:
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""_oauth_redirect_uri decides where Google sends the OAuth code back. In production it must ALWAYS
|
||||
use the fixed configured URL and never trust the request Host (a forged Host must not be able to
|
||||
redirect the code); in local dev it honors a small allowlist of dev origins so a login started on the
|
||||
Vite server (:5173) returns to :5173."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import auth
|
||||
|
||||
|
||||
def _req(host):
|
||||
"""Minimal stand-in for a Starlette Request: only .headers.get('host') is read."""
|
||||
return SimpleNamespace(headers={"host": host} if host is not None else {})
|
||||
|
||||
|
||||
def _dev_settings():
|
||||
return SimpleNamespace(
|
||||
session_https_only=False,
|
||||
oauth_redirect_url="http://localhost:8080/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
def _prod_settings():
|
||||
return SimpleNamespace(
|
||||
session_https_only=True,
|
||||
oauth_redirect_url="https://siftlode.example.eu/auth/callback",
|
||||
)
|
||||
|
||||
|
||||
def test_dev_known_host_returns_to_that_origin(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _dev_settings())
|
||||
# Each allowlisted host keeps the whole round-trip on one origin (all four are registered in the
|
||||
# Google console), so the redirect_uri matches the host the flow started on.
|
||||
assert auth._oauth_redirect_uri(_req("localhost:5173")) == "http://localhost:5173/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req("localhost:8080")) == "http://localhost:8080/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req("127.0.0.1:5173")) == "http://127.0.0.1:5173/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req("127.0.0.1:8080")) == "http://127.0.0.1:8080/auth/callback"
|
||||
|
||||
|
||||
def test_dev_host_is_case_insensitive(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _dev_settings())
|
||||
assert auth._oauth_redirect_uri(_req("LOCALHOST:5173")) == "http://localhost:5173/auth/callback"
|
||||
|
||||
|
||||
def test_dev_unknown_or_missing_host_falls_back_to_configured(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _dev_settings())
|
||||
# An origin not on the allowlist (or a request with no Host) must not drive the redirect.
|
||||
assert auth._oauth_redirect_uri(_req("evil.example.com")) == "http://localhost:8080/auth/callback"
|
||||
assert auth._oauth_redirect_uri(_req(None)) == "http://localhost:8080/auth/callback"
|
||||
|
||||
|
||||
def test_prod_ignores_host_even_when_it_matches_a_dev_origin(monkeypatch):
|
||||
monkeypatch.setattr(auth, "settings", _prod_settings())
|
||||
# The host-injection guard: in production the fixed URL wins regardless of the Host header.
|
||||
assert (
|
||||
auth._oauth_redirect_uri(_req("localhost:5173"))
|
||||
== "https://siftlode.example.eu/auth/callback"
|
||||
)
|
||||
assert (
|
||||
auth._oauth_redirect_uri(_req("attacker.test"))
|
||||
== "https://siftlode.example.eu/auth/callback"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""_claim_running is the single-run guard that both the scheduled fire and the manual trigger go
|
||||
through (at the top of _job): holding _activity_lock across the check-and-set means exactly one
|
||||
caller can hold a job's "running" claim at a time, and it's claimable again once released."""
|
||||
from app import scheduler
|
||||
|
||||
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
|
||||
|
||||
|
||||
def _release():
|
||||
scheduler._record(_JOB, running=False)
|
||||
|
||||
|
||||
def test_claim_is_exclusive_until_released():
|
||||
_release()
|
||||
# First caller claims it.
|
||||
assert scheduler._claim_running(_JOB) is True
|
||||
# A second caller is refused while the first still holds it (the TOCTOU the fix closes).
|
||||
assert scheduler._claim_running(_JOB) is False
|
||||
assert scheduler._claim_running(_JOB) is False
|
||||
# _job's finally releases the claim...
|
||||
_release()
|
||||
# ...and now it can be claimed again.
|
||||
assert scheduler._claim_running(_JOB) is True
|
||||
_release()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging
|
||||
directory and the download share endpoints. A row must be non-demo AND active AND not-suspended."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.userscope import is_messageable_user
|
||||
|
||||
|
||||
def _user(is_demo=False, is_active=True, is_suspended=False):
|
||||
return SimpleNamespace(is_demo=is_demo, is_active=is_active, is_suspended=is_suspended)
|
||||
|
||||
|
||||
def test_a_real_active_human_is_messageable():
|
||||
assert is_messageable_user(_user()) is True
|
||||
|
||||
|
||||
def test_none_is_not_messageable():
|
||||
assert is_messageable_user(None) is False
|
||||
|
||||
|
||||
def test_demo_suspended_or_inactive_are_not_messageable():
|
||||
assert is_messageable_user(_user(is_demo=True)) is False
|
||||
assert is_messageable_user(_user(is_suspended=True)) is False
|
||||
assert is_messageable_user(_user(is_active=False)) is False
|
||||
@@ -199,6 +199,15 @@ export default function App() {
|
||||
stripUrlParams();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
|
||||
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
stripUrlParams();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
|
||||
// drop it; act once `me` is known, and only for an admin (others just land on their default page).
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import KeyGate from "./KeyGate";
|
||||
|
||||
@@ -49,14 +49,32 @@ export default function ChatThread({
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const out: Record<number, string> = {};
|
||||
// Fetch the partner key lazily inside the try (only when there's a ciphertext message) and
|
||||
// once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and
|
||||
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
|
||||
let pub: string | null = null;
|
||||
let refreshed = false;
|
||||
for (const m of q.data?.items ?? []) {
|
||||
if (m.ciphertext && m.iv) {
|
||||
try {
|
||||
const pub = await partnerPub(partnerId);
|
||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||
} catch {
|
||||
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
||||
if (!m.ciphertext || !m.iv) continue;
|
||||
try {
|
||||
if (!pub) pub = await partnerPub(partnerId);
|
||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||
} catch {
|
||||
// A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so
|
||||
// this device's cached public key is stale. Refresh it ONCE per pass and retry — that
|
||||
// also re-derives future sends under the current key. A genuinely bad message still ends
|
||||
// up "can't decrypt".
|
||||
if (!refreshed) {
|
||||
refreshed = true;
|
||||
try {
|
||||
pub = await refreshPartnerPub(partnerId);
|
||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||
continue;
|
||||
} catch {
|
||||
/* fall through to the error label below */
|
||||
}
|
||||
}
|
||||
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
||||
}
|
||||
}
|
||||
if (!cancelled) setPlain(out);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Lock, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
|
||||
// (the Messages page, a dock window) swaps out of the gate automatically.
|
||||
// (the Messages page, a dock window) swaps out of the gate automatically. In unlock mode it also
|
||||
// offers a forgotten-passphrase reset (the only escape from the set-once wall).
|
||||
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -17,9 +21,16 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const configured = keyQ.data?.configured ?? false;
|
||||
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
|
||||
// have no password to check. Reactive passive observer of the App-owned ["me"] query (enabled:
|
||||
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
|
||||
const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
|
||||
const hasPassword = meQ.data?.has_password ?? false;
|
||||
|
||||
const [view, setView] = useState<"gate" | "reset">("gate");
|
||||
const [pass, setPass] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [curPass, setCurPass] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -43,9 +54,79 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
}
|
||||
}
|
||||
|
||||
function toReset() {
|
||||
setErr(null);
|
||||
setPass("");
|
||||
setConfirm("");
|
||||
setView("reset");
|
||||
}
|
||||
function cancelReset() {
|
||||
setErr(null);
|
||||
setCurPass("");
|
||||
setView("gate");
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
setErr(null);
|
||||
if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass"));
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.resetMessageKey(hasPassword ? curPass : undefined);
|
||||
// Server key + dead history are gone; wipe this device's identity too so the gate re-appears
|
||||
// in setup mode. Invalidate last so the refetch sees configured:false.
|
||||
await e2ee.resetLocal(meId);
|
||||
setCurPass("");
|
||||
setView("gate");
|
||||
qc.invalidateQueries({ queryKey: ["message-key"] });
|
||||
} catch (e) {
|
||||
setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (keyQ.isLoading)
|
||||
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
|
||||
|
||||
if (view === "reset")
|
||||
return (
|
||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0" />
|
||||
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
|
||||
{hasPassword && (
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={curPass}
|
||||
onChange={(e) => setCurPass(e.target.value)}
|
||||
placeholder={t("messages.resetCurrentPass")}
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-red-400">{err}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelReset}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={doReset}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-red-500/90 text-white hover:bg-red-500 transition disabled:opacity-40"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t("messages.resetButton")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -65,7 +146,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
||||
placeholder={t("messages.passphrase")}
|
||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
className={inputCls}
|
||||
/>
|
||||
{!configured && (
|
||||
<input
|
||||
@@ -73,7 +154,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("messages.passphraseConfirm")}
|
||||
className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{err && <div className="text-sm text-red-400">{err}</div>}
|
||||
@@ -85,6 +166,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
<Lock className="w-4 h-4" />
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
{t("messages.forgotPass")}
|
||||
</button>
|
||||
)}
|
||||
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,6 +88,8 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [editPass, setEditPass] = useState(false);
|
||||
const [newPass, setNewPass] = useState("");
|
||||
const fullUrl = window.location.origin + link.url;
|
||||
|
||||
const copy = async () => {
|
||||
@@ -106,6 +108,16 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||
onSuccess: onChanged,
|
||||
});
|
||||
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
|
||||
// password_version server-side, which invalidates any watch grant issued under the old password.
|
||||
const savePass = useMutation({
|
||||
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
|
||||
onSuccess: () => {
|
||||
setEditPass(false);
|
||||
setNewPass("");
|
||||
onChanged();
|
||||
},
|
||||
});
|
||||
|
||||
const badges: string[] = [];
|
||||
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
||||
@@ -131,6 +143,23 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewPass("");
|
||||
setEditPass((v) => !v);
|
||||
}}
|
||||
title={
|
||||
link.has_password
|
||||
? t("downloads.share.changePassword")
|
||||
: t("downloads.share.setPassword")
|
||||
}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface transition",
|
||||
link.has_password ? "text-accent" : "text-muted hover:text-fg",
|
||||
)}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (
|
||||
@@ -165,6 +194,49 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
{t("downloads.share.allowDownload")}
|
||||
</label>
|
||||
</div>
|
||||
{editPass && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPass}
|
||||
onChange={(e) => setNewPass(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && newPass.trim() && savePass.mutate(newPass.trim())
|
||||
}
|
||||
placeholder={t(
|
||||
link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword",
|
||||
)}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => savePass.mutate(newPass.trim())}
|
||||
disabled={savePass.isPending || !newPass.trim()}
|
||||
className="px-2 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-40 transition"
|
||||
>
|
||||
{t("downloads.share.savePassword")}
|
||||
</button>
|
||||
{link.has_password && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
title: t("downloads.share.removePassword"),
|
||||
message: t("downloads.share.removePasswordConfirm"),
|
||||
confirmLabel: t("downloads.share.removePassword"),
|
||||
danger: true,
|
||||
})
|
||||
)
|
||||
savePass.mutate("");
|
||||
}}
|
||||
disabled={savePass.isPending}
|
||||
className="px-2 py-1 rounded-md text-xs text-muted hover:text-red-400 transition"
|
||||
>
|
||||
{t("downloads.share.removePassword")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,24 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
const PASSWORD_MIN = 10;
|
||||
|
||||
type Mode = "signin" | "register" | "forgot" | "reset";
|
||||
type Mode = "signin" | "register" | "forgot" | "reset" | "resend";
|
||||
|
||||
// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries)
|
||||
// keeps adding a new mode a one-line change and makes a missing key a compile error.
|
||||
const TITLE_KEY: Record<Mode, string> = {
|
||||
signin: "welcome.auth.signinTitle",
|
||||
register: "welcome.auth.createTitle",
|
||||
forgot: "welcome.auth.forgotTitle",
|
||||
reset: "welcome.auth.resetTitle",
|
||||
resend: "welcome.auth.resendTitle",
|
||||
};
|
||||
const SUBMIT_KEY: Record<Mode, string> = {
|
||||
signin: "welcome.auth.signinButton",
|
||||
register: "welcome.auth.createButton",
|
||||
forgot: "welcome.auth.forgotButton",
|
||||
reset: "welcome.auth.resetButton",
|
||||
resend: "welcome.auth.resendButton",
|
||||
};
|
||||
|
||||
// Public landing + authentication. One scrollable page: a hero with the auth card (email+
|
||||
// password, Google, explicit demo) over a short marketing pitch with feature cards. Shown
|
||||
@@ -267,8 +284,10 @@ function AuthCard() {
|
||||
const resetToken = hashParams.get("reset");
|
||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
||||
const login = params.get("login"); // Google login blocked → "suspended"
|
||||
const deleted = params.get("deleted"); // self-service account deletion just completed
|
||||
// Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the
|
||||
// first deliberate action, like the verify banner — otherwise it lingers stalely on later screens.
|
||||
const [login, setLogin] = useState<string | null>(() => params.get("login"));
|
||||
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||
@@ -321,13 +340,26 @@ function AuthCard() {
|
||||
const reset = () => {
|
||||
setError("");
|
||||
setDone("");
|
||||
// The verify/login banners are one-time entry notices — dismiss once the user takes any
|
||||
// deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens.
|
||||
setVerify(null);
|
||||
setLogin(null);
|
||||
};
|
||||
|
||||
// Shared by the expired-link banner CTA and the sign-in "didn't get the email?" link.
|
||||
const goResend = () => {
|
||||
reset();
|
||||
setMode("resend");
|
||||
};
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
const mail = email.trim().toLowerCase();
|
||||
if ((mode === "signin" || mode === "register" || mode === "forgot") && !EMAIL_RE.test(mail)) {
|
||||
if (
|
||||
(mode === "signin" || mode === "register" || mode === "forgot" || mode === "resend") &&
|
||||
!EMAIL_RE.test(mail)
|
||||
) {
|
||||
setError(t("welcome.auth.invalidEmail"));
|
||||
return;
|
||||
}
|
||||
@@ -348,6 +380,9 @@ function AuthCard() {
|
||||
} else if (mode === "forgot") {
|
||||
await api.requestPasswordReset(mail);
|
||||
setDone(t("welcome.auth.forgotDone"));
|
||||
} else if (mode === "resend") {
|
||||
await api.resendVerification(mail);
|
||||
setDone(t("welcome.auth.resendDone"));
|
||||
} else if (mode === "reset") {
|
||||
await api.confirmPasswordReset(resetToken ?? "", password);
|
||||
setDone(t("welcome.auth.resetDone"));
|
||||
@@ -383,14 +418,7 @@ function AuthCard() {
|
||||
|
||||
const inputCls =
|
||||
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
|
||||
const titleKey =
|
||||
mode === "register"
|
||||
? "welcome.auth.createTitle"
|
||||
: mode === "forgot"
|
||||
? "welcome.auth.forgotTitle"
|
||||
: mode === "reset"
|
||||
? "welcome.auth.resetTitle"
|
||||
: "welcome.auth.signinTitle";
|
||||
const titleKey = TITLE_KEY[mode];
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
||||
@@ -398,10 +426,20 @@ function AuthCard() {
|
||||
|
||||
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
||||
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
|
||||
{verify === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
<button onClick={goResend} className="font-semibold text-accent hover:underline">
|
||||
{t("welcome.auth.resendCta")}
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
|
||||
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
||||
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
|
||||
{login === "oauth_cancelled" && (
|
||||
<Banner tone="warn">{t("welcome.auth.oauthCancelled")}</Banner>
|
||||
)}
|
||||
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
|
||||
|
||||
{done ? (
|
||||
@@ -431,7 +469,7 @@ function AuthCard() {
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{mode !== "forgot" && (
|
||||
{mode !== "forgot" && mode !== "resend" && (
|
||||
<input
|
||||
type="password"
|
||||
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
||||
@@ -452,17 +490,7 @@ function AuthCard() {
|
||||
disabled={busy}
|
||||
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||
>
|
||||
{busy
|
||||
? t("welcome.auth.working")
|
||||
: t(
|
||||
mode === "register"
|
||||
? "welcome.auth.createButton"
|
||||
: mode === "forgot"
|
||||
? "welcome.auth.forgotButton"
|
||||
: mode === "reset"
|
||||
? "welcome.auth.resetButton"
|
||||
: "welcome.auth.signinButton",
|
||||
)}
|
||||
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -504,6 +532,14 @@ function AuthCard() {
|
||||
|
||||
{mode === "signin" && (
|
||||
<>
|
||||
{/* A verification email can be lost or never arrive, so offer a resend path from
|
||||
sign-in too — not only from the expired-link banner (needs a broken link to show). */}
|
||||
<button
|
||||
onClick={goResend}
|
||||
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
|
||||
>
|
||||
{t("welcome.auth.resendLink")}
|
||||
</button>
|
||||
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{t("welcome.auth.or")}
|
||||
|
||||
@@ -118,7 +118,13 @@
|
||||
"views": "{{n}} views",
|
||||
"expiresOn": "expires {{date}}",
|
||||
"hasPassword": "password",
|
||||
"downloadable": "downloadable"
|
||||
"downloadable": "downloadable",
|
||||
"setPassword": "Set a password",
|
||||
"changePassword": "Change password",
|
||||
"newPassword": "New password",
|
||||
"savePassword": "Save",
|
||||
"removePassword": "Remove",
|
||||
"removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password."
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Cancel download?",
|
||||
|
||||
@@ -31,5 +31,12 @@
|
||||
"passTooShort": "Use at least 6 characters.",
|
||||
"passMismatch": "The passphrases don't match.",
|
||||
"wrongPass": "Wrong passphrase.",
|
||||
"setupFailed": "Couldn't set up secure messaging. Please try again."
|
||||
"setupFailed": "Couldn't set up secure messaging. Please try again.",
|
||||
"forgotPass": "Forgot your passphrase?",
|
||||
"resetTitle": "Reset secure messaging",
|
||||
"resetWarn": "This permanently erases your entire message history — every conversation, for you and the people you messaged — because it can no longer be decrypted. Then you can set a new passphrase and start fresh. This can't be undone.",
|
||||
"resetCurrentPass": "Current account password",
|
||||
"resetNeedPass": "Enter your current account password to confirm.",
|
||||
"resetButton": "Reset & erase history",
|
||||
"resetFailed": "Couldn't reset secure messaging. Please try again."
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.",
|
||||
"error": "Couldn't link the Google account. Please try again."
|
||||
},
|
||||
"googleCancelled": "Google sign-in was cancelled.",
|
||||
"password": {
|
||||
"title": "Password",
|
||||
"setHint": "A password is set. You can sign in with your email and password.",
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
"createButton": "Create account",
|
||||
"forgotButton": "Send reset link",
|
||||
"resetButton": "Set password",
|
||||
"resendButton": "Resend verification",
|
||||
"resendTitle": "Resend verification email",
|
||||
"resendLink": "Didn't get the verification email?",
|
||||
"createLink": "Create an account",
|
||||
"forgotLink": "Forgot password?",
|
||||
"backToSignin": "Back to sign in",
|
||||
@@ -72,6 +75,9 @@
|
||||
"resetDone": "Your password has been set — you can sign in now.",
|
||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
||||
"resendCta": "Send a new link",
|
||||
"resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.",
|
||||
"oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.",
|
||||
"accessRequested": "Thanks — we recorded your request. An admin will review it.",
|
||||
"accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.",
|
||||
"suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).",
|
||||
|
||||
@@ -118,7 +118,13 @@
|
||||
"views": "{{n}} megtekintés",
|
||||
"expiresOn": "lejár: {{date}}",
|
||||
"hasPassword": "jelszó",
|
||||
"downloadable": "letölthető"
|
||||
"downloadable": "letölthető",
|
||||
"setPassword": "Jelszó beállítása",
|
||||
"changePassword": "Jelszó módosítása",
|
||||
"newPassword": "Új jelszó",
|
||||
"savePassword": "Mentés",
|
||||
"removePassword": "Törlés",
|
||||
"removePasswordConfirm": "Ezzel a link publikus lesz — jelszó nélkül bárki megnézheti, akinek megvan az URL."
|
||||
},
|
||||
"confirm": {
|
||||
"cancelTitle": "Megszakítod a letöltést?",
|
||||
|
||||
@@ -31,5 +31,12 @@
|
||||
"passTooShort": "Legalább 6 karakter legyen.",
|
||||
"passMismatch": "A jelszavak nem egyeznek.",
|
||||
"wrongPass": "Hibás jelszó.",
|
||||
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra."
|
||||
"setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra.",
|
||||
"forgotPass": "Elfelejtetted a jelszavad?",
|
||||
"resetTitle": "Biztonságos üzenetküldés visszaállítása",
|
||||
"resetWarn": "Ez véglegesen törli a teljes üzenet-előzményedet — minden beszélgetést, nálad és a partnereidnél is —, mert az többé nem visszafejthető. Utána új jelszót állíthatsz be és tiszta lappal indulhatsz. Ez nem visszavonható.",
|
||||
"resetCurrentPass": "Jelenlegi fiók-jelszó",
|
||||
"resetNeedPass": "Add meg a jelenlegi fiók-jelszavad a megerősítéshez.",
|
||||
"resetButton": "Visszaállítás és előzmény törlése",
|
||||
"resetFailed": "A biztonságos üzenetküldés visszaállítása nem sikerült. Próbáld újra."
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.",
|
||||
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
|
||||
},
|
||||
"googleCancelled": "A Google-belépés megszakadt.",
|
||||
"password": {
|
||||
"title": "Jelszó",
|
||||
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
"createButton": "Fiók létrehozása",
|
||||
"forgotButton": "Visszaállító link küldése",
|
||||
"resetButton": "Jelszó beállítása",
|
||||
"resendButton": "Megerősítő link újraküldése",
|
||||
"resendTitle": "Megerősítő e-mail újraküldése",
|
||||
"resendLink": "Nem kaptad meg a megerősítő e-mailt?",
|
||||
"createLink": "Hozz létre egy fiókot",
|
||||
"forgotLink": "Elfelejtetted a jelszót?",
|
||||
"backToSignin": "Vissza a belépéshez",
|
||||
@@ -72,6 +75,9 @@
|
||||
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
|
||||
"verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.",
|
||||
"verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.",
|
||||
"resendCta": "Új link küldése",
|
||||
"resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.",
|
||||
"oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.",
|
||||
"accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.",
|
||||
"accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.",
|
||||
"suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).",
|
||||
|
||||
@@ -1557,6 +1557,14 @@ export const api = {
|
||||
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
||||
// Re-issue a verification link (the first one expired, or was lost). Uniform response — never
|
||||
// reveals whether the email has a pending account.
|
||||
resendVerification: (email: string): Promise<{ status: string }> =>
|
||||
req(
|
||||
"/auth/verify/resend",
|
||||
{ method: "POST", body: JSON.stringify({ email }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
// SA4: sign every OTHER session for this account out (keeps the current one).
|
||||
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
||||
// Set or change the signed-in account's password (errors shown inline via quiet).
|
||||
@@ -1615,6 +1623,14 @@ export const api = {
|
||||
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
||||
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
||||
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
|
||||
// Forgotten-passphrase reset: drops the server key (and the now-undecryptable history) so a new
|
||||
// passphrase can be set. Password accounts must pass their current password; errors shown inline.
|
||||
resetMessageKey: (currentPassword?: string): Promise<{ configured: boolean }> =>
|
||||
req(
|
||||
"/api/messages/keys/reset",
|
||||
{ method: "POST", body: JSON.stringify({ current_password: currentPassword ?? null }) },
|
||||
{ quiet: true },
|
||||
),
|
||||
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
|
||||
req(`/api/messages/keys/${userId}`),
|
||||
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
|
||||
|
||||
@@ -85,6 +85,16 @@ export async function clearDevice(userId: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Wipe this device's E2EE identity: forget the unlocked key + every derived conversation key,
|
||||
// delete the persisted copy, and notify observers so the gate re-appears. Used by the passphrase
|
||||
// reset, which also drops the server key so the next screen is a fresh setup.
|
||||
export async function resetLocal(userId: number): Promise<void> {
|
||||
privKey = null;
|
||||
convKeys.clear();
|
||||
await clearDevice(userId);
|
||||
emitUnlock();
|
||||
}
|
||||
|
||||
// --- key derivation ---
|
||||
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [
|
||||
@@ -175,6 +185,13 @@ export async function unlock(
|
||||
await installKey(userId, await importPrivate(pkcs8));
|
||||
}
|
||||
|
||||
// Drop the cached derived conversation key for a partner. convKeyFor caches by partnerId alone, so
|
||||
// after a partner rotates their public key (a passphrase reset) the caller must call this before
|
||||
// re-deriving with the fresh key — otherwise the stale key would be returned.
|
||||
export function forgetConv(partnerId: number): void {
|
||||
convKeys.delete(partnerId);
|
||||
}
|
||||
|
||||
// --- per-conversation encryption ---
|
||||
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
||||
const cached = convKeys.get(partnerId);
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "./api";
|
||||
import { isUnlocked, subscribeUnlock } from "./e2ee";
|
||||
import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee";
|
||||
|
||||
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
||||
|
||||
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
|
||||
export const POLL_MS = 20000;
|
||||
|
||||
// Set-once cache of partner public keys (the server is the directory; keys never change).
|
||||
// Cache of partner public keys (the server is the directory). Keys are stable in normal use, but a
|
||||
// partner can rotate theirs via a passphrase reset — refreshPartnerPub() evicts a stale entry (and
|
||||
// its derived conversation key) so the next fetch picks up the new key.
|
||||
const pubCache = new Map<number, string>();
|
||||
export async function partnerPub(partnerId: number): Promise<string> {
|
||||
const cached = pubCache.get(partnerId);
|
||||
@@ -19,6 +21,22 @@ export async function partnerPub(partnerId: number): Promise<string> {
|
||||
return r.public_key;
|
||||
}
|
||||
|
||||
// Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt
|
||||
// fails, which can mean the partner reset their passphrase and published a NEW keypair; refetching
|
||||
// lets this device both read their new messages and encrypt future ones under the current key.
|
||||
//
|
||||
// KNOWN LIMITATION: this refresh is wired only to the DECRYPT path. If you send a partner a message
|
||||
// AFTER they rotated their key but BEFORE any of their messages arrived to trigger a refresh, that
|
||||
// one send is encrypted under the stale cached key and is undecryptable for them (subsequent sends
|
||||
// self-heal once an incoming message refreshes the cache). Accepted for now — same cross-user
|
||||
// staleness envelope as the multi-device note in useKeyState; a fuller fix needs a key TTL / a
|
||||
// send-time freshness check, which is out of the current no-forward-secrecy scope.
|
||||
export async function refreshPartnerPub(partnerId: number): Promise<string> {
|
||||
pubCache.delete(partnerId);
|
||||
forgetConv(partnerId);
|
||||
return partnerPub(partnerId);
|
||||
}
|
||||
|
||||
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
|
||||
function useUnlocked(): boolean {
|
||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
||||
@@ -28,6 +46,14 @@ function useUnlocked(): boolean {
|
||||
// local unlock state gives the real gate: "setup" if the server has no key (even if a stale
|
||||
// private key lingers in this browser — setup overwrites it), "unlock" if the server has a key
|
||||
// but this device hasn't unlocked it, else "ready".
|
||||
//
|
||||
// KNOWN LIMITATION (multi-device, passphrase reset): this doesn't compare the locally-unlocked
|
||||
// key's public half against the server's current public_key, so if the user resets their passphrase
|
||||
// on ANOTHER device (new keypair), this already-unlocked device stays "ready" with the now-dead old
|
||||
// key and silently fails to decrypt new messages until it's manually re-unlocked. Accepted for now:
|
||||
// it fits the E2EE scope (static pairwise key, no forward secrecy) and self-heals once the device is
|
||||
// re-unlocked with the new passphrase. Incoming-message decrypt failures on the CURRENT device DO
|
||||
// self-heal via refreshPartnerPub() (a partner's rotation), which covers the common single-device case.
|
||||
export type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||
export function useKeyState(): KeyState {
|
||||
const unlocked = useUnlocked();
|
||||
|
||||
@@ -5,10 +5,15 @@ import react from "@vitejs/plugin-react";
|
||||
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
|
||||
// Docker port publish may not answer — that surfaces as proxy connection failures.
|
||||
const target = "http://127.0.0.1:8080";
|
||||
// changeOrigin:false keeps the original `Host: localhost:5173` header when proxying to the backend
|
||||
// (the TCP target is still 127.0.0.1:8080 — changeOrigin only rewrites the header). The backend uses
|
||||
// that header to send the OAuth redirect_uri back to :5173, so a Google login started on the Vite dev
|
||||
// server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.)
|
||||
const proxyOpts = { target, changeOrigin: false };
|
||||
const proxy = {
|
||||
"/api": target,
|
||||
"/auth": target,
|
||||
"/healthz": target,
|
||||
"/api": proxyOpts,
|
||||
"/auth": proxyOpts,
|
||||
"/healthz": proxyOpts,
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
Reference in New Issue
Block a user