package auth import ( "database/sql" "encoding/json" "errors" "net/http" "strings" "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/internal/httputil" ) // UserStore provisions and reads accounts. Petal has no signup flow: a row // appears the first time someone Authentik vouches for signs in, and that is // the only way one is ever created. type UserStore struct { db *sql.DB } // NewUserStore returns a store backed by the given database. func NewUserStore(sqlDB *sql.DB) *UserStore { return &UserStore{db: sqlDB} } // Upsert records the account behind an OIDC login, keyed by the issuer's // subject id. // // The subject is the id — not the email, which people change and which // Authentik does not promise is stable. Email and display name are refreshed on // every login so a rename upstream shows up here; pair_lang is deliberately not // touched, because it is Petal's own setting rather than the IdP's. func (u *UserStore) Upsert(sub, email, displayName string) error { if sub == "" { return errors.New("oidc: empty subject") } if displayName == "" { displayName = email } _, err := u.db.Exec( `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET email = excluded.email, display_name = excluded.display_name`, sub, email, displayName, ) return err } // Get loads one account. func (u *UserStore) Get(id string) (db.User, error) { var user db.User err := u.db.QueryRow( `SELECT id, email, COALESCE(display_name, ''), created_at, pair_lang, direction FROM users WHERE id = ?`, id, ).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.PairLang, &user.Direction) return user, err } // MeHandler reports who the caller is. The frontend uses it to namespace // per-account browser state and to show the signed-in writer; it sits behind // the auth middleware, so reaching it at all already proves a valid session. func (u *UserStore) MeHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, err := u.Get(UserID(r.Context())) if err != nil { httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in") return } httputil.WriteJSON(w, http.StatusOK, user) } } // The pairs a writer may actually choose, in the order the picker offers them. // // This is deliberately *not* internal/llm's list of languages. That one names // every pair the prompts know how to talk about, which is a cheap thing to add; // this one names the pairs Petal can render itself in, which requires a langpack // on the frontend. Accepting a code with no pack would leave her looking at // Chinese with no way back except another guess, so the server refuses it. es // joined on the day its pack landed, not before. // // These four are now every pair PairLang names on the frontend, which makes the // two lists look redundant. They are not: the next pair will exist in the type // and in the prompts long before it has copy, and this list is the one that // says a writer may actually be sent there. var shippedPairs = []string{"zh", "pt-PT", "fr", "es"} func pairIsShipped(lang string) bool { for _, p := range shippedPairs { if p == lang { return true } } return false } // The two directions a pair can be travelled in. `DirectionLearningEn` is the // original assumption made explicit: the writer is native in X and practising // English. `DirectionLearningPair` is the other way round. const ( DirectionLearningEn = "learning_en" DirectionLearningPair = "learning_pair" ) // The pairs whose *learner* direction Petal can actually serve, which is a // narrower thing than a shipped pair and narrower again than a langpack. // // Turning a pair around needs data no langpack carries: a word list to segment // with, and a dictionary that reads from the pair language into English. Chinese // has both as of Phase 26 (CC-CEDICT + jieba); French, Spanish and Portuguese // have neither yet, and — unlike a missing pack, which leaves a writer looking // at copy she cannot read — a missing word list would leave her looking at an // editor that silently does nothing when she hovers. Both are bad; only one is // legible as a bug. So the server refuses, for the same reason and by the same // mechanism as `shippedPairs`. // // This list is expected to grow one pair at a time and never to be inferred: // segmentation is a property of a writing system, and there is no rule that // derives "has a word list" from a language code. var learnerPairs = []string{"zh"} // SupportsLearnerDirection reports whether a pair can be turned around. func SupportsLearnerDirection(lang string) bool { for _, p := range learnerPairs { if p == lang { return true } } return false } func directionIsKnown(d string) bool { return d == DirectionLearningEn || d == DirectionLearningPair } // SetPair moves an account to another (English + X) pair, in a given direction. // // The two are written together because they constrain each other: a direction is // only meaningful for a pair that can be travelled in it, and validating them a // field at a time would let a two-step change pass through a state that neither // step is allowed to leave behind. func (u *UserStore) SetPair(id, lang, direction string) error { if !pairIsShipped(lang) { return errors.New("auth: unshipped pair language " + lang) } if !directionIsKnown(direction) { return errors.New("auth: unknown direction " + direction) } if direction == DirectionLearningPair && !SupportsLearnerDirection(lang) { return errors.New("auth: no learner direction for " + lang) } res, err := u.db.Exec( `UPDATE users SET pair_lang = ?, direction = ? WHERE id = ?`, lang, direction, id) if err != nil { return err } if n, err := res.RowsAffected(); err == nil && n == 0 { return sql.ErrNoRows } return nil } // UpdateMeHandler changes the caller's own settings: which language Petal // speaks alongside her English, and which of the two she is learning. // // It answers with the whole updated user rather than an empty 204 so the client // has one shape to trust: /api/me and this return the same thing, and the app // re-reads the pair from the response instead of assuming its request took. // // The pair language reaches further than the UI copy — it picks her Hunspell // dictionary, her read-aloud voice, which word-lookup provider answers, and the // language the prompts ask the model to explain in. All of those read // `users.pair_lang` at use time, so all of them follow from this one write. // // Both fields are optional and each defaults to what the account already has, so // the picker can send one without knowing the other. That matters for the // combination this endpoint exists to prevent: a client that sent only // `pair_lang: "fr"` while the account sat on `learning_pair` would otherwise ask // for French-with-segmentation, which does not exist. Here it is one decision // with one validation, and the answer carries whatever actually landed. func (u *UserStore) UpdateMeHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body struct { PairLang *string `json:"pair_lang"` Direction *string `json:"direction"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { httputil.BadRequest(w, "invalid request body") return } id := UserID(r.Context()) current, err := u.Get(id) if err != nil { // Only a missing row means "not signed in". A dictionary-file or // SQLite fault answered as 401 would trip the client's session // interceptor and throw a writer out of an app she is still signed // in to — the same distinction SetPair's error branch makes below. if errors.Is(err, sql.ErrNoRows) { httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in") return } httputil.ServerError(w, err) return } lang, direction := current.PairLang, current.Direction if body.PairLang != nil { lang = strings.TrimSpace(*body.PairLang) } if body.Direction != nil { direction = strings.TrimSpace(*body.Direction) } if !pairIsShipped(lang) { // Name the ones that work. A writer who lands here has picked from a // stale client, and "not a language" tells her nothing. httputil.BadRequest(w, "unsupported language pair — Petal speaks "+strings.Join(shippedPairs, ", ")) return } if !directionIsKnown(direction) { httputil.BadRequest(w, "unknown direction — expected "+DirectionLearningEn+" or "+DirectionLearningPair) return } if direction == DirectionLearningPair && !SupportsLearnerDirection(lang) { // Refused rather than quietly downgraded to learning_en. A silent // downgrade would leave the writer looking at an editor that behaves // like the one she just tried to leave, with nothing to read as an // explanation — and the caller cannot tell the two outcomes apart // without diffing the response it was given. httputil.BadRequest(w, "Petal can only be learned toward "+strings.Join(learnerPairs, ", ")+" so far") return } if err := u.SetPair(id, lang, direction); err != nil { if errors.Is(err, sql.ErrNoRows) { httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in") return } httputil.ServerError(w, err) return } user, err := u.Get(id) if err != nil { httputil.ServerError(w, err) return } httputil.WriteJSON(w, http.StatusOK, user) } } // Allowlist decides which of Authentik's users may write in this Petal. // Authentik fronts several applications; being a valid user there does not mean // being a user here. // // An entry matches a subject id or an email address, case-insensitively. Both // are accepted on purpose: a subject is an opaque uuid nobody can know before // that person's first login, so a subject-only list means the operator must let // someone in, read a log line, and edit config — whereas an email is knowable in // advance. An empty list allows everyone the IdP authenticates, which is the // right default for a single-household instance. type Allowlist map[string]bool // ParseAllowlist builds an Allowlist from a comma-separated env value. func ParseAllowlist(raw string) Allowlist { list := Allowlist{} for _, part := range strings.Split(raw, ",") { if p := strings.ToLower(strings.TrimSpace(part)); p != "" { list[p] = true } } return list } // Permits reports whether this login may proceed. func (a Allowlist) Permits(sub, email string) bool { if len(a) == 0 { return true } return a[strings.ToLower(sub)] || (email != "" && a[strings.ToLower(email)]) }