Files
Ilia Denisov 9101aba816 phase 7+: i18n primitive + login language picker + autocomplete-off
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.

Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.

Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.

Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
  body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:14:40 +02:00

478 lines
14 KiB
Go

package restapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/mail"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var errPublicAuthIdentityNotApplicable = errors.New("public auth identity does not apply to this route")
type malformedJSONRequestError struct {
message string
reason PublicMalformedRequestReason
}
func (e *malformedJSONRequestError) Error() string {
if e == nil {
return ""
}
return e.message
}
type publicAuthIdentity struct {
kind string
value string
}
// AuthServiceClient defines the consumer-side contract used by public auth
// REST handlers to delegate unauthenticated authentication commands to the
// Auth / Session Service.
type AuthServiceClient interface {
// SendEmailCode starts a login challenge for input.Email and returns the
// challenge identifier that the client must later confirm.
SendEmailCode(ctx context.Context, input SendEmailCodeInput) (SendEmailCodeResult, error)
// ConfirmEmailCode completes a previously issued challenge, registers
// input.ClientPublicKey for the new device session, and returns the created
// device session identifier.
ConfirmEmailCode(ctx context.Context, input ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error)
}
// SendEmailCodeInput describes the public REST and adapter payload used to
// request a login code for a single e-mail address.
type SendEmailCodeInput struct {
// Email is the single client e-mail address that should receive the login
// code challenge.
Email string `json:"email"`
// Locale is the optional BCP 47 language tag the caller wants the
// auth-mail in. The body field is the canonical channel because Safari
// silently drops JS-set Accept-Language headers; when set, it overrides
// the request Accept-Language for preferred-language resolution.
Locale string `json:"locale,omitempty"`
// PreferredLanguage stores the canonical BCP 47 language tag derived
// from Locale (preferred) or the Accept-Language header (fallback) for
// upstream auth-mail localization and create-only user registration
// context.
PreferredLanguage string `json:"-"`
}
// SendEmailCodeResult describes the public REST and adapter payload returned
// after the Auth / Session Service creates a login challenge.
type SendEmailCodeResult struct {
// ChallengeID identifies the issued challenge that must be confirmed by the
// client in the next public auth step.
ChallengeID string `json:"challenge_id"`
}
// ConfirmEmailCodeInput describes the public REST and adapter payload used to
// complete a previously issued login challenge.
type ConfirmEmailCodeInput struct {
// ChallengeID identifies the challenge previously returned by
// SendEmailCode.
ChallengeID string `json:"challenge_id"`
// Code is the verification code delivered to the client by the Auth /
// Session Service.
Code string `json:"code"`
// ClientPublicKey is the standard base64-encoded raw 32-byte Ed25519 public
// key that should be registered for the created device session.
ClientPublicKey string `json:"client_public_key"`
// TimeZone is the client-selected IANA time zone name forwarded to the
// Auth / Session Service as registration context for first-time user
// creation.
TimeZone string `json:"time_zone"`
}
// ConfirmEmailCodeResult describes the public REST and adapter payload
// returned after the Auth / Session Service creates a device session.
type ConfirmEmailCodeResult struct {
// DeviceSessionID is the stable identifier of the created device session.
DeviceSessionID string `json:"device_session_id"`
}
// AuthServiceError allows an auth adapter to project a stable public REST
// error without teaching the gateway transport layer about upstream business
// rules.
type AuthServiceError struct {
// StatusCode is the HTTP status that the public REST handler should expose.
StatusCode int
// Code is the stable edge-level error code written into the JSON envelope.
Code string
// Message is the human-readable client-safe error description.
Message string
}
// Error returns a readable representation of the projected auth service error.
func (e *AuthServiceError) Error() string {
if e == nil {
return ""
}
switch {
case strings.TrimSpace(e.Code) == "" && strings.TrimSpace(e.Message) == "":
return http.StatusText(e.normalizedStatusCode())
case strings.TrimSpace(e.Code) == "":
return e.Message
case strings.TrimSpace(e.Message) == "":
return e.Code
default:
return e.Code + ": " + e.Message
}
}
func (e *AuthServiceError) normalizedStatusCode() int {
if e == nil || e.StatusCode < 400 || e.StatusCode > 599 {
return http.StatusInternalServerError
}
return e.StatusCode
}
func (e *AuthServiceError) normalizedCode() string {
if e == nil {
return errorCodeInternalError
}
code := strings.TrimSpace(e.Code)
if code == "" {
switch e.normalizedStatusCode() {
case http.StatusServiceUnavailable:
return errorCodeServiceUnavailable
case http.StatusBadRequest:
return errorCodeInvalidRequest
default:
return errorCodeInternalError
}
}
return code
}
func (e *AuthServiceError) normalizedMessage() string {
if e == nil {
return "internal server error"
}
message := strings.TrimSpace(e.Message)
if message == "" {
switch e.normalizedStatusCode() {
case http.StatusServiceUnavailable:
return "auth service is unavailable"
case http.StatusBadRequest:
return "request is invalid"
default:
return "internal server error"
}
}
return message
}
// unavailableAuthServiceClient keeps the public auth surface mounted until a
// concrete upstream adapter is wired into the gateway process.
type unavailableAuthServiceClient struct{}
func (unavailableAuthServiceClient) SendEmailCode(context.Context, SendEmailCodeInput) (SendEmailCodeResult, error) {
return SendEmailCodeResult{}, &AuthServiceError{
StatusCode: http.StatusServiceUnavailable,
Code: errorCodeServiceUnavailable,
Message: "auth service is unavailable",
}
}
func (unavailableAuthServiceClient) ConfirmEmailCode(context.Context, ConfirmEmailCodeInput) (ConfirmEmailCodeResult, error) {
return ConfirmEmailCodeResult{}, &AuthServiceError{
StatusCode: http.StatusServiceUnavailable,
Code: errorCodeServiceUnavailable,
Message: "auth service is unavailable",
}
}
func handleSendEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var input SendEmailCodeInput
if err := decodeJSONRequest(c.Request, &input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
if err := validateSendEmailCodeInput(&input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
// Body locale wins over the request header so Safari clients,
// which cannot set Accept-Language from JavaScript, can still
// pick a non-system mail language. Empty / malformed values
// fall through resolvePreferredLanguage to the default.
if strings.TrimSpace(input.Locale) != "" {
input.PreferredLanguage = resolvePreferredLanguage(input.Locale)
} else {
input.PreferredLanguage = resolvePreferredLanguage(c.Request.Header.Get("Accept-Language"))
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := authService.SendEmailCode(callCtx, input)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable")
return
}
abortWithAuthServiceError(c, err)
return
}
if err := validateSendEmailCodeResult(&result); err != nil {
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
return
}
c.JSON(http.StatusOK, result)
}
}
func handleConfirmEmailCode(authService AuthServiceClient, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var input ConfirmEmailCodeInput
if err := decodeJSONRequest(c.Request, &input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
if err := validateConfirmEmailCodeInput(&input); err != nil {
abortInvalidRequest(c, err.Error())
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := authService.ConfirmEmailCode(callCtx, input)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
abortWithError(c, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "auth service is unavailable")
return
}
abortWithAuthServiceError(c, err)
return
}
if err := validateConfirmEmailCodeResult(&result); err != nil {
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
return
}
c.JSON(http.StatusOK, result)
}
}
func abortInvalidRequest(c *gin.Context, message string) {
abortWithError(c, http.StatusBadRequest, errorCodeInvalidRequest, message)
}
func abortWithAuthServiceError(c *gin.Context, err error) {
var authErr *AuthServiceError
if errors.As(err, &authErr) {
abortWithError(c, authErr.normalizedStatusCode(), authErr.normalizedCode(), authErr.normalizedMessage())
return
}
abortWithError(c, http.StatusInternalServerError, errorCodeInternalError, "internal server error")
}
func decodeJSONRequest(r *http.Request, target any) error {
if r == nil || r.Body == nil {
return &malformedJSONRequestError{
message: "request body must not be empty",
reason: PublicMalformedRequestReasonEmptyBody,
}
}
return decodeJSONReader(r.Body, target)
}
func decodeJSONBytes(bodyBytes []byte, target any) error {
return decodeJSONReader(bytes.NewReader(bodyBytes), target)
}
func decodeJSONReader(reader io.Reader, target any) error {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return describeJSONDecodeError(err)
}
if err := decoder.Decode(&struct{}{}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &malformedJSONRequestError{
message: "request body must contain a single JSON object",
reason: PublicMalformedRequestReasonMultipleJSONObjects,
}
}
return &malformedJSONRequestError{
message: "request body must contain a single JSON object",
reason: PublicMalformedRequestReasonMultipleJSONObjects,
}
}
func describeJSONDecodeError(err error) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return &malformedJSONRequestError{
message: "request body must not be empty",
reason: PublicMalformedRequestReasonEmptyBody,
}
case errors.As(err, &syntaxErr):
return &malformedJSONRequestError{
message: "request body contains malformed JSON",
reason: PublicMalformedRequestReasonMalformedJSON,
}
case errors.Is(err, io.ErrUnexpectedEOF):
return &malformedJSONRequestError{
message: "request body contains malformed JSON",
reason: PublicMalformedRequestReasonMalformedJSON,
}
case errors.As(err, &typeErr):
if strings.TrimSpace(typeErr.Field) != "" {
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
reason: PublicMalformedRequestReasonInvalidJSONValue,
}
}
return &malformedJSONRequestError{
message: "request body contains an invalid JSON value",
reason: PublicMalformedRequestReasonInvalidJSONValue,
}
case strings.HasPrefix(err.Error(), "json: unknown field "):
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
reason: PublicMalformedRequestReasonUnknownField,
}
default:
return &malformedJSONRequestError{
message: "request body contains invalid JSON",
reason: PublicMalformedRequestReasonMalformedJSON,
}
}
}
func validateSendEmailCodeInput(input *SendEmailCodeInput) error {
input.Email = strings.TrimSpace(input.Email)
if input.Email == "" {
return errors.New("email must not be empty")
}
parsedAddress, err := mail.ParseAddress(input.Email)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != input.Email {
return errors.New("email must be a single valid email address")
}
return nil
}
func validateSendEmailCodeResult(result *SendEmailCodeResult) error {
result.ChallengeID = strings.TrimSpace(result.ChallengeID)
if result.ChallengeID == "" {
return errors.New("auth service returned an empty challenge_id")
}
return nil
}
func validateConfirmEmailCodeInput(input *ConfirmEmailCodeInput) error {
input.ChallengeID = strings.TrimSpace(input.ChallengeID)
if input.ChallengeID == "" {
return errors.New("challenge_id must not be empty")
}
input.Code = strings.TrimSpace(input.Code)
if input.Code == "" {
return errors.New("code must not be empty")
}
input.ClientPublicKey = strings.TrimSpace(input.ClientPublicKey)
if input.ClientPublicKey == "" {
return errors.New("client_public_key must not be empty")
}
input.TimeZone = strings.TrimSpace(input.TimeZone)
if input.TimeZone == "" {
return errors.New("time_zone must not be empty")
}
return nil
}
func validateConfirmEmailCodeResult(result *ConfirmEmailCodeResult) error {
result.DeviceSessionID = strings.TrimSpace(result.DeviceSessionID)
if result.DeviceSessionID == "" {
return errors.New("auth service returned an empty device_session_id")
}
return nil
}
func malformedRequestReasonFromError(err error) (PublicMalformedRequestReason, bool) {
var malformedErr *malformedJSONRequestError
if !errors.As(err, &malformedErr) {
return "", false
}
return malformedErr.reason, true
}
func extractPublicAuthIdentity(requestPath string, bodyBytes []byte) (publicAuthIdentity, error) {
switch requestPath {
case "/api/v1/public/auth/send-email-code":
var input SendEmailCodeInput
if err := decodeJSONBytes(bodyBytes, &input); err != nil {
return publicAuthIdentity{}, err
}
if err := validateSendEmailCodeInput(&input); err != nil {
return publicAuthIdentity{}, err
}
return publicAuthIdentity{
kind: "email",
value: input.Email,
}, nil
case "/api/v1/public/auth/confirm-email-code":
var input ConfirmEmailCodeInput
if err := decodeJSONBytes(bodyBytes, &input); err != nil {
return publicAuthIdentity{}, err
}
if err := validateConfirmEmailCodeInput(&input); err != nil {
return publicAuthIdentity{}, err
}
return publicAuthIdentity{
kind: "challenge",
value: input.ChallengeID,
}, nil
default:
return publicAuthIdentity{}, errPublicAuthIdentityNotApplicable
}
}