Merge: promote dev to prod
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 fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.requests import HTTPConnection
|
from starlette.requests import HTTPConnection
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import email as email_mod
|
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)
|
_register_limiter = RateLimiter(max_events=5, window_seconds=300)
|
||||||
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
_login_limiter = RateLimiter(max_events=10, window_seconds=300)
|
||||||
_reset_limiter = RateLimiter(max_events=5, 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)
|
_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
|
# 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.
|
# 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
|
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")
|
@router.get("/login")
|
||||||
async def login(request: Request, db: Session = Depends(get_db)):
|
async def login(request: Request, db: Session = Depends(get_db)):
|
||||||
# access_type=offline ensures a refresh_token on first authorization. We avoid
|
# 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 RedirectResponse(url="/")
|
||||||
return await client.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
_oauth_redirect_uri(request),
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
prompt="select_account",
|
prompt="select_account",
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
@@ -274,10 +304,27 @@ async def callback(
|
|||||||
client = google_oauth(db)
|
client = google_oauth(db)
|
||||||
if client is None:
|
if client is None:
|
||||||
return RedirectResponse(url="/")
|
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:
|
try:
|
||||||
token = await client.authorize_access_token(request)
|
token = await client.authorize_access_token(request)
|
||||||
except OAuthError as exc:
|
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")
|
userinfo = token.get("userinfo")
|
||||||
if not userinfo or not userinfo.get("sub"):
|
if not userinfo or not userinfo.get("sub"):
|
||||||
@@ -286,12 +333,8 @@ async def callback(
|
|||||||
sub = userinfo["sub"]
|
sub = userinfo["sub"]
|
||||||
email = (userinfo.get("email") or "").lower()
|
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:
|
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):
|
if not is_allowed(db, email):
|
||||||
log.warning("Login denied (not approved): %s", email or "<no 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(
|
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
|
"""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
|
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."""
|
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` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
|
||||||
explicit = request.session.pop("oauth_link_explicit", False)
|
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).
|
# The marker must match the live session user (guards a stale/forged marker).
|
||||||
if link_uid != request.session.get("user_id"):
|
if link_uid != request.session.get("user_id"):
|
||||||
return RedirectResponse(url="/?link=error")
|
return RedirectResponse(url="/?link=error")
|
||||||
@@ -670,7 +719,14 @@ def _register_account(email: str, password: str) -> None:
|
|||||||
email_verified=not email_ok,
|
email_verified=not email_ok,
|
||||||
)
|
)
|
||||||
db.add(user)
|
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)
|
upsert_pending_invite(db, email) # admin-approval gate (commits)
|
||||||
if email_ok:
|
if email_ok:
|
||||||
raw = _issue_token(db, user, "verify", VERIFY_TTL)
|
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}
|
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")
|
@router.get("/verify")
|
||||||
def verify_email(token: str, db: Session = Depends(get_db)):
|
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
|
"""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:
|
if user is None:
|
||||||
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.")
|
||||||
user.password_hash = hash_password(new_password)
|
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.
|
bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions.
|
||||||
# Burn any other outstanding reset tokens for this user.
|
# Burn any other outstanding reset tokens for this user.
|
||||||
for row in db.execute(
|
for row in db.execute(
|
||||||
@@ -951,7 +1045,7 @@ async def link_google(
|
|||||||
request.session["oauth_link_explicit"] = True
|
request.session["oauth_link_explicit"] = True
|
||||||
return await client.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
_oauth_redirect_uri(request),
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
prompt="select_account",
|
prompt="select_account",
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
@@ -1016,7 +1110,7 @@ async def upgrade(
|
|||||||
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
scope = WRITE_SCOPES if access == "write" else READ_SCOPES
|
||||||
return await client.authorize_redirect(
|
return await client.authorize_redirect(
|
||||||
request,
|
request,
|
||||||
settings.oauth_redirect_url,
|
_oauth_redirect_uri(request),
|
||||||
access_type="offline",
|
access_type="offline",
|
||||||
prompt="consent",
|
prompt="consent",
|
||||||
include_granted_scopes="true",
|
include_granted_scopes="true",
|
||||||
|
|||||||
@@ -33,13 +33,17 @@ def _sign(msg: str) -> str:
|
|||||||
).hexdigest()[:32]
|
).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
def make_grant(token: str, now: float | None = None) -> str:
|
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>`."""
|
"""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
|
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:
|
if not grant or "." not in grant:
|
||||||
return False
|
return False
|
||||||
exp_s, sig = grant.split(".", 1)
|
exp_s, sig = grant.split(".", 1)
|
||||||
@@ -49,7 +53,7 @@ def check_grant(token: str, grant: str | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
if exp < datetime.now(timezone.utc).timestamp():
|
if exp < datetime.now(timezone.utc).timestamp():
|
||||||
return False
|
return False
|
||||||
return hmac.compare_digest(sig, _sign(f"{token}.{exp}"))
|
return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}"))
|
||||||
|
|
||||||
|
|
||||||
# --- serialization -------------------------------------------------------------------------
|
# --- serialization -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -943,6 +943,10 @@ class DownloadLink(Base, TimestampMixin):
|
|||||||
Boolean, default=False, server_default="false"
|
Boolean, default=False, server_default="false"
|
||||||
)
|
)
|
||||||
password_hash: Mapped[str | None] = mapped_column(Text)
|
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))
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from app.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.security import hash_password
|
from app.security import hash_password
|
||||||
|
from app.userscope import is_messageable_user, messageable_clauses
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
|
||||||
@@ -666,7 +667,9 @@ def share_download(
|
|||||||
recipient = db.execute(
|
recipient = db.execute(
|
||||||
select(User).where(func.lower(User.email) == target)
|
select(User).where(func.lower(User.email) == target)
|
||||||
).scalar_one_or_none()
|
).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.")
|
raise HTTPException(status_code=404, detail="No user with that email.")
|
||||||
if recipient.id == user.id:
|
if recipient.id == user.id:
|
||||||
raise HTTPException(status_code=400, detail="That's already your download.")
|
raise HTTPException(status_code=400, detail="That's already your download.")
|
||||||
@@ -741,11 +744,12 @@ def remove_shared_with_me(
|
|||||||
def share_recipients(
|
def share_recipients(
|
||||||
user: User = Depends(require_human), db: Session = Depends(get_db)
|
user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> list[dict]:
|
) -> 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 = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
select(User)
|
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))
|
.order_by(func.lower(User.email))
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
@@ -834,6 +838,9 @@ def update_link(
|
|||||||
if "password" in payload:
|
if "password" in payload:
|
||||||
pw = (payload.get("password") or "").strip()
|
pw = (payload.get("password") or "").strip()
|
||||||
link.password_hash = hash_password(pw) if pw else None
|
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()
|
db.commit()
|
||||||
return linksmod.owner_view(link)
|
return linksmod.owner_view(link)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_human, resolved_user_id
|
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.models import Message, MessageKey, User
|
||||||
from app.ratelimit import RateLimiter
|
from app.ratelimit import RateLimiter
|
||||||
from app.realtime import manager
|
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"])
|
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_PARTNER_ID = 0 # synthetic conversation partner for system messages
|
||||||
SYSTEM_NAME = "Siftlode"
|
SYSTEM_NAME = "Siftlode"
|
||||||
_send_limiter = RateLimiter(max_events=30, window_seconds=60)
|
_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
|
# 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).
|
# (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
|
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):
|
class SendIn(BaseModel):
|
||||||
recipient_id: int
|
recipient_id: int
|
||||||
ciphertext: str
|
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:
|
def ensure_welcome(db: Session, user: User) -> None:
|
||||||
@@ -172,11 +168,63 @@ def setup_keys(
|
|||||||
return {"configured": True}
|
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}")
|
@router.get("/keys/{user_id}")
|
||||||
def public_key(
|
def public_key(
|
||||||
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> 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)
|
k = db.get(MessageKey, user_id)
|
||||||
if k is None:
|
if k is None:
|
||||||
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.")
|
||||||
@@ -195,7 +243,7 @@ def list_recipients(
|
|||||||
db.execute(
|
db.execute(
|
||||||
select(User)
|
select(User)
|
||||||
.join(MessageKey, MessageKey.user_id == User.id)
|
.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)))
|
.order_by(func.lower(func.coalesce(User.display_name, User.email)))
|
||||||
)
|
)
|
||||||
.scalars()
|
.scalars()
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di
|
|||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
link.view_count += 1
|
link.view_count += 1
|
||||||
db.commit()
|
db.commit()
|
||||||
grant = linksmod.make_grant(token)
|
grant = linksmod.make_grant(token, link.password_version)
|
||||||
return {
|
return {
|
||||||
"needs_password": False,
|
"needs_password": False,
|
||||||
**linksmod.public_meta(
|
**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
|
"""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."""
|
`allow_download` link serves it as an attachment. Password links require a valid grant."""
|
||||||
link = _link_or_404(db, token)
|
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.")
|
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||||
asset = _ready_asset(db, link)
|
asset = _ready_asset(db, link)
|
||||||
path = storage.safe_abs_path(settings.download_root, asset.rel_path)
|
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
|
"""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."""
|
and the link-preview image). Password links require a valid grant, like the file."""
|
||||||
link = _link_or_404(db, token)
|
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.")
|
raise HTTPException(status_code=403, detail="This link needs a password.")
|
||||||
job, asset = _ready_target(db, link)
|
job, asset = _ready_target(db, link)
|
||||||
if not asset.poster_path:
|
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:
|
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()
|
db = SessionLocal()
|
||||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
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):
|
def sink(current, total, phase):
|
||||||
_record(name, progress={"current": current, "total": total, "phase": phase})
|
_record(name, progress={"current": current, "total": total, "phase": phase})
|
||||||
@@ -172,7 +178,7 @@ def _job(name: str, fn) -> None:
|
|||||||
try:
|
try:
|
||||||
if is_sync_paused(db):
|
if is_sync_paused(db):
|
||||||
logger.info("job %s skipped (sync paused)", name)
|
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)
|
last_result="paused", progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
||||||
@@ -182,7 +188,7 @@ def _job(name: str, fn) -> None:
|
|||||||
result = fn(db)
|
result = fn(db)
|
||||||
summary = _summarize(result)
|
summary = _summarize(result)
|
||||||
logger.info("job %s -> %s", name, 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)
|
last_result=summary, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "ok", summary)
|
_notify_done(db, actor_id, name, "ok", summary)
|
||||||
@@ -191,12 +197,15 @@ def _job(name: str, fn) -> None:
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("job %s failed", name)
|
logger.exception("job %s failed", name)
|
||||||
err = str(exc) or exc.__class__.__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)
|
last_error=err, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "error", err)
|
_notify_done(db, actor_id, name, "error", err)
|
||||||
_audit_run(db, actor_id, name, "error", err, None)
|
_audit_run(db, actor_id, name, "error", err, None)
|
||||||
finally:
|
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()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -380,6 +389,18 @@ def _is_running(job_id: str) -> bool:
|
|||||||
return bool(_activity.get(job_id, {}).get("running"))
|
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]:
|
def running_job_ids() -> set[str]:
|
||||||
"""Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the
|
"""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."""
|
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)
|
fn = JOB_FUNCS.get(job_id)
|
||||||
if fn is None:
|
if fn is None:
|
||||||
raise KeyError(job_id)
|
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):
|
if _is_running(job_id):
|
||||||
return "already_running"
|
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:
|
class TestGrant:
|
||||||
def test_a_fresh_grant_verifies_for_its_own_token(self):
|
def test_a_fresh_grant_verifies_for_its_own_token(self):
|
||||||
grant = make_grant("tok")
|
grant = make_grant("tok", 0)
|
||||||
assert check_grant("tok", grant) is True
|
assert check_grant("tok", grant, 0) is True
|
||||||
|
|
||||||
def test_a_grant_does_not_verify_for_a_different_token(self):
|
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.
|
# 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):
|
def test_a_tampered_signature_is_rejected(self):
|
||||||
exp, _sig = make_grant("tok").split(".", 1)
|
exp, _sig = make_grant("tok", 0).split(".", 1)
|
||||||
assert check_grant("tok", f"{exp}.deadbeef") is False
|
assert check_grant("tok", f"{exp}.deadbeef", 0) is False
|
||||||
|
|
||||||
def test_a_tampered_expiry_is_rejected(self):
|
def test_a_tampered_expiry_is_rejected(self):
|
||||||
# Extending the exp field invalidates the signature (which covers token.exp).
|
# Extending the exp field invalidates the signature (which covers token.exp.version).
|
||||||
_exp, sig = make_grant("tok").split(".", 1)
|
_exp, sig = make_grant("tok", 0).split(".", 1)
|
||||||
future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL
|
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):
|
def test_an_expired_grant_is_rejected(self):
|
||||||
past = datetime.now(timezone.utc).timestamp() - 2 * _GRANT_TTL
|
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):
|
def test_malformed_grants_are_rejected_not_crashed(self):
|
||||||
for bad in [None, "", "no-dot", "notanumber.sig"]:
|
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:
|
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();
|
stripUrlParams();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // 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
|
// 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
|
// 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).
|
// 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 { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { relativeTime } from "../lib/format";
|
import { relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
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 * as e2ee from "../lib/e2ee";
|
||||||
import KeyGate from "./KeyGate";
|
import KeyGate from "./KeyGate";
|
||||||
|
|
||||||
@@ -49,14 +49,32 @@ export default function ChatThread({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const out: Record<number, string> = {};
|
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 ?? []) {
|
for (const m of q.data?.items ?? []) {
|
||||||
if (m.ciphertext && m.iv) {
|
if (!m.ciphertext || !m.iv) continue;
|
||||||
try {
|
try {
|
||||||
const pub = await partnerPub(partnerId);
|
if (!pub) pub = await partnerPub(partnerId);
|
||||||
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv);
|
||||||
} catch {
|
} catch {
|
||||||
out[m.id] = "⚠ " + t("messages.cantDecrypt");
|
// 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);
|
if (!cancelled) setPlain(out);
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Lock, ShieldCheck } from "lucide-react";
|
import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api, HttpError } from "../lib/api";
|
||||||
import * as e2ee from "../lib/e2ee";
|
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.
|
// 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
|
// 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 }) {
|
export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -17,9 +21,16 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
const configured = keyQ.data?.configured ?? false;
|
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 [pass, setPass] = useState("");
|
||||||
const [confirm, setConfirm] = useState("");
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [curPass, setCurPass] = useState("");
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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)
|
if (keyQ.isLoading)
|
||||||
return <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
|
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 (
|
return (
|
||||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<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)}
|
onChange={(e) => setPass(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
||||||
placeholder={t("messages.passphrase")}
|
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 && (
|
{!configured && (
|
||||||
<input
|
<input
|
||||||
@@ -73,7 +154,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
placeholder={t("messages.passphraseConfirm")}
|
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>}
|
{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" />
|
<Lock className="w-4 h-4" />
|
||||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||||
</button>
|
</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>}
|
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [editPass, setEditPass] = useState(false);
|
||||||
|
const [newPass, setNewPass] = useState("");
|
||||||
const fullUrl = window.location.origin + link.url;
|
const fullUrl = window.location.origin + link.url;
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
@@ -106,6 +108,16 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
mutationFn: () => api.revokeDownloadLink(link.id),
|
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||||
onSuccess: onChanged,
|
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[] = [];
|
const badges: string[] = [];
|
||||||
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
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" />}
|
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (
|
if (
|
||||||
@@ -165,6 +194,49 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
|||||||
{t("downloads.share.allowDownload")}
|
{t("downloads.share.allowDownload")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,24 @@ import LanguageSwitcher from "./LanguageSwitcher";
|
|||||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||||
const PASSWORD_MIN = 10;
|
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+
|
// 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
|
// 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 resetToken = hashParams.get("reset");
|
||||||
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow)
|
||||||
const bounced = params.get("access"); // Google denied → "requested" | "denied"
|
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
|
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
|
// Verification outcome: from POSTing the fragment token (new flow), or the legacy
|
||||||
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
// ?verify=ok|invalid redirect (pre-SB3 emails still in flight).
|
||||||
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
const [verify, setVerify] = useState<string | null>(() => params.get("verify"));
|
||||||
@@ -321,13 +340,26 @@ function AuthCard() {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
setError("");
|
setError("");
|
||||||
setDone("");
|
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) {
|
async function onSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
reset();
|
reset();
|
||||||
const mail = email.trim().toLowerCase();
|
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"));
|
setError(t("welcome.auth.invalidEmail"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -348,6 +380,9 @@ function AuthCard() {
|
|||||||
} else if (mode === "forgot") {
|
} else if (mode === "forgot") {
|
||||||
await api.requestPasswordReset(mail);
|
await api.requestPasswordReset(mail);
|
||||||
setDone(t("welcome.auth.forgotDone"));
|
setDone(t("welcome.auth.forgotDone"));
|
||||||
|
} else if (mode === "resend") {
|
||||||
|
await api.resendVerification(mail);
|
||||||
|
setDone(t("welcome.auth.resendDone"));
|
||||||
} else if (mode === "reset") {
|
} else if (mode === "reset") {
|
||||||
await api.confirmPasswordReset(resetToken ?? "", password);
|
await api.confirmPasswordReset(resetToken ?? "", password);
|
||||||
setDone(t("welcome.auth.resetDone"));
|
setDone(t("welcome.auth.resetDone"));
|
||||||
@@ -383,14 +418,7 @@ function AuthCard() {
|
|||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
|
"w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent";
|
||||||
const titleKey =
|
const titleKey = TITLE_KEY[mode];
|
||||||
mode === "register"
|
|
||||||
? "welcome.auth.createTitle"
|
|
||||||
: mode === "forgot"
|
|
||||||
? "welcome.auth.forgotTitle"
|
|
||||||
: mode === "reset"
|
|
||||||
? "welcome.auth.resetTitle"
|
|
||||||
: "welcome.auth.signinTitle";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
|
<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). */}
|
{/* One-time status banners from a redirect (Google-denied, email verification). */}
|
||||||
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
|
{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 === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
|
||||||
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
|
||||||
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</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>}
|
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
|
||||||
|
|
||||||
{done ? (
|
{done ? (
|
||||||
@@ -431,7 +469,7 @@ function AuthCard() {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mode !== "forgot" && (
|
{mode !== "forgot" && mode !== "resend" && (
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
autoComplete={mode === "signin" ? "current-password" : "new-password"}
|
||||||
@@ -452,17 +490,7 @@ function AuthCard() {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
|
||||||
>
|
>
|
||||||
{busy
|
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
|
||||||
? t("welcome.auth.working")
|
|
||||||
: t(
|
|
||||||
mode === "register"
|
|
||||||
? "welcome.auth.createButton"
|
|
||||||
: mode === "forgot"
|
|
||||||
? "welcome.auth.forgotButton"
|
|
||||||
: mode === "reset"
|
|
||||||
? "welcome.auth.resetButton"
|
|
||||||
: "welcome.auth.signinButton",
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -504,6 +532,14 @@ function AuthCard() {
|
|||||||
|
|
||||||
{mode === "signin" && (
|
{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">
|
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">
|
||||||
<span className="h-px flex-1 bg-border" />
|
<span className="h-px flex-1 bg-border" />
|
||||||
{t("welcome.auth.or")}
|
{t("welcome.auth.or")}
|
||||||
|
|||||||
@@ -118,7 +118,13 @@
|
|||||||
"views": "{{n}} views",
|
"views": "{{n}} views",
|
||||||
"expiresOn": "expires {{date}}",
|
"expiresOn": "expires {{date}}",
|
||||||
"hasPassword": "password",
|
"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": {
|
"confirm": {
|
||||||
"cancelTitle": "Cancel download?",
|
"cancelTitle": "Cancel download?",
|
||||||
|
|||||||
@@ -31,5 +31,12 @@
|
|||||||
"passTooShort": "Use at least 6 characters.",
|
"passTooShort": "Use at least 6 characters.",
|
||||||
"passMismatch": "The passphrases don't match.",
|
"passMismatch": "The passphrases don't match.",
|
||||||
"wrongPass": "Wrong passphrase.",
|
"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.",
|
"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."
|
"error": "Couldn't link the Google account. Please try again."
|
||||||
},
|
},
|
||||||
|
"googleCancelled": "Google sign-in was cancelled.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Password",
|
"title": "Password",
|
||||||
"setHint": "A password is set. You can sign in with your email and password.",
|
"setHint": "A password is set. You can sign in with your email and password.",
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
"createButton": "Create account",
|
"createButton": "Create account",
|
||||||
"forgotButton": "Send reset link",
|
"forgotButton": "Send reset link",
|
||||||
"resetButton": "Set password",
|
"resetButton": "Set password",
|
||||||
|
"resendButton": "Resend verification",
|
||||||
|
"resendTitle": "Resend verification email",
|
||||||
|
"resendLink": "Didn't get the verification email?",
|
||||||
"createLink": "Create an account",
|
"createLink": "Create an account",
|
||||||
"forgotLink": "Forgot password?",
|
"forgotLink": "Forgot password?",
|
||||||
"backToSignin": "Back to sign in",
|
"backToSignin": "Back to sign in",
|
||||||
@@ -72,6 +75,9 @@
|
|||||||
"resetDone": "Your password has been set — you can sign in now.",
|
"resetDone": "Your password has been set — you can sign in now.",
|
||||||
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
"verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.",
|
||||||
"verifyInvalid": "That verification link is invalid or has expired.",
|
"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.",
|
"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.",
|
"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).",
|
"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",
|
"views": "{{n}} megtekintés",
|
||||||
"expiresOn": "lejár: {{date}}",
|
"expiresOn": "lejár: {{date}}",
|
||||||
"hasPassword": "jelszó",
|
"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": {
|
"confirm": {
|
||||||
"cancelTitle": "Megszakítod a letöltést?",
|
"cancelTitle": "Megszakítod a letöltést?",
|
||||||
|
|||||||
@@ -31,5 +31,12 @@
|
|||||||
"passTooShort": "Legalább 6 karakter legyen.",
|
"passTooShort": "Legalább 6 karakter legyen.",
|
||||||
"passMismatch": "A jelszavak nem egyeznek.",
|
"passMismatch": "A jelszavak nem egyeznek.",
|
||||||
"wrongPass": "Hibás jelszó.",
|
"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.",
|
"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."
|
"error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra."
|
||||||
},
|
},
|
||||||
|
"googleCancelled": "A Google-belépés megszakadt.",
|
||||||
"password": {
|
"password": {
|
||||||
"title": "Jelszó",
|
"title": "Jelszó",
|
||||||
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
|
"setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.",
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
"createButton": "Fiók létrehozása",
|
"createButton": "Fiók létrehozása",
|
||||||
"forgotButton": "Visszaállító link küldése",
|
"forgotButton": "Visszaállító link küldése",
|
||||||
"resetButton": "Jelszó beállítása",
|
"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",
|
"createLink": "Hozz létre egy fiókot",
|
||||||
"forgotLink": "Elfelejtetted a jelszót?",
|
"forgotLink": "Elfelejtetted a jelszót?",
|
||||||
"backToSignin": "Vissza a belépéshez",
|
"backToSignin": "Vissza a belépéshez",
|
||||||
@@ -72,6 +75,9 @@
|
|||||||
"resetDone": "A jelszavad be van állítva — most már beléphetsz.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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).",
|
"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).
|
// Confirm an email-verification token the SPA read from the URL fragment (SB3).
|
||||||
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
verifyEmail: (token: string): Promise<{ ok: boolean }> =>
|
||||||
req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
|
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).
|
// SA4: sign every OTHER session for this account out (keeps the current one).
|
||||||
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
|
||||||
// Set or change the signed-in account's password (errors shown inline via quiet).
|
// 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"),
|
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
||||||
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
||||||
req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
|
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 }> =>
|
messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
|
||||||
req(`/api/messages/keys/${userId}`),
|
req(`/api/messages/keys/${userId}`),
|
||||||
messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
|
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 ---
|
// --- key derivation ---
|
||||||
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
|
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||||
const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [
|
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));
|
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 ---
|
// --- per-conversation encryption ---
|
||||||
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
async function convKeyFor(partnerId: number, partnerPubB64: string): Promise<CryptoKey> {
|
||||||
const cached = convKeys.get(partnerId);
|
const cached = convKeys.get(partnerId);
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "./api";
|
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
|
export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id
|
||||||
|
|
||||||
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
|
// Safety-net poll interval for message queries; live updates arrive over the WebSocket.
|
||||||
export const POLL_MS = 20000;
|
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>();
|
const pubCache = new Map<number, string>();
|
||||||
export async function partnerPub(partnerId: number): Promise<string> {
|
export async function partnerPub(partnerId: number): Promise<string> {
|
||||||
const cached = pubCache.get(partnerId);
|
const cached = pubCache.get(partnerId);
|
||||||
@@ -19,6 +21,22 @@ export async function partnerPub(partnerId: number): Promise<string> {
|
|||||||
return r.public_key;
|
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.
|
// Live "is secure messaging unlocked on this device" — shared so the page and the dock agree.
|
||||||
function useUnlocked(): boolean {
|
function useUnlocked(): boolean {
|
||||||
return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked);
|
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
|
// 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
|
// 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".
|
// 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 type KeyState = "loading" | "setup" | "unlock" | "ready";
|
||||||
export function useKeyState(): KeyState {
|
export function useKeyState(): KeyState {
|
||||||
const unlocked = useUnlocked();
|
const unlocked = useUnlocked();
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ export interface ReleaseEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.53.0",
|
||||||
|
date: "2026-07-23",
|
||||||
|
summary:
|
||||||
|
"Account & sign-in dead ends fixed, a way out of a forgotten messaging passphrase, and share-link password controls — plus security hardening.",
|
||||||
|
features: [
|
||||||
|
"Forgot your secure-messaging passphrase? You can now reset it from the unlock screen and start fresh with a new one. Resetting permanently clears your existing message history (it can't be decrypted without the old passphrase) — you're warned clearly before it happens.",
|
||||||
|
"Didn't get your verification email, or the link expired? You can now request a new one right from the sign-in screen.",
|
||||||
|
"Share links: you can now change or remove the password on an existing link, not just when creating it. Changing it immediately stops the old password from working.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"Completing a password reset now also verifies your email — so an account that never clicked the original verification link isn't left stuck.",
|
||||||
|
"Cancelling Google sign-in now returns you to the login page with a clear message instead of a raw error page; the same for cancelling a Google connect/upgrade from inside the app.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"Security hardening: rotating a share-link password now instantly revokes links already handed out; the share recipient list and messaging key lookup no longer reveal suspended/deactivated accounts; and assorted race-condition and abuse-surface fixes.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.52.0",
|
version: "0.52.0",
|
||||||
date: "2026-07-22",
|
date: "2026-07-22",
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// Docker port publish may not answer — that surfaces as proxy connection failures.
|
||||||
const target = "http://127.0.0.1:8080";
|
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 = {
|
const proxy = {
|
||||||
"/api": target,
|
"/api": proxyOpts,
|
||||||
"/auth": target,
|
"/auth": proxyOpts,
|
||||||
"/healthz": target,
|
"/healthz": proxyOpts,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user