Compare commits
5 Commits
phase-32.1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bf219f50a | ||
|
|
91af10b572 | ||
|
|
8446a77605 | ||
| 66fc107eb9 | |||
| bd2cc0c245 |
@@ -40,7 +40,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
|||||||
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
- **Address proof.** `users.email_verified_at` records when the account's owner proved control of the address (`User.EmailVerifiedAt`). Exactly two flows write it: the mailed activation code (`POST /activate`, `POST /activate-by-code` and the invitation link, through `VerifyActivationCode`) and a completed reset-code password reset (`POST /reset-password`). `POST /register` in `auto` or `admin` mode, `RegisterUser`, an administrator's create and the admin Activate action, `POST /change-password` and social sign-ins record none. The proof follows the address: an email change through `POST /update` or the admin Users form clears it, while a change of letter case only keeps it. The column is never mass-assigned and is in no user API payload, admin form or list. No migration or code path backfills it: accounts from before v0.1.5 have no proof. An application that trusts its imported addresses may set the column in its own data migration; this plugin does not. A social-login plugin links a verified provider's identity onto an existing account only when it is set.
|
||||||
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
||||||
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
||||||
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
|
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created. A refusal by `user.scope:<scope>` or `user.permission:<code>` answers 403 with the existing `error` and `message` plus `missing_scope` or `missing_permission` naming what is missing.
|
||||||
- **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`:
|
- **Member token scopes.** A member personal token (`usr_`) carries the built-in scopes `read`, `write` and `ai`, plus any scope another plugin declares with `pact.HasFrontendTokenScopes`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -53,7 +53,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
|||||||
```
|
```
|
||||||
|
|
||||||
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog` and as `pact.MemberTokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a reserved name `backend` or `offline_access`, a name two plugins declare (the error names both), an empty label or description, an `Implies` other than empty, `read` or `write` (the error names the plugin and that value), or a label or description phrase key (containing `::`) that the translator does not have. `Implies` is only those three values: empty means the declared scope is not implied, and the built-ins `read`, `write` and `ai` keep it empty. Implied names are not stored on a token row or a fingerprint. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
|
Boot compiles the catalog from the activated plugins (`classes.CompileAPITokenScopes`, published as `*classes.APITokenScopeCatalog` and as `pact.MemberTokenScopeCatalog`) and fails on a name that does not pass `pact.ValidTokenScopeName`, a built-in redeclared, a reserved name `backend` or `offline_access`, a name two plugins declare (the error names both), an empty label or description, an `Implies` other than empty, `read` or `write` (the error names the plugin and that value), or a label or description phrase key (containing `::`) that the translator does not have. `Implies` is only those three values: empty means the declared scope is not implied, and the built-ins `read`, `write` and `ai` keep it empty. Implied names are not stored on a token row or a fingerprint. `POST /tokens` and `classes.MintAPITokenFor(app, …)` accept the scopes of the application's own catalog (`classes.APITokenScopesFor(app)`) and answer an undeclared one with the usual 422 `The selected scope is invalid.`; the console's `user:token:create` uses `MintAPITokenFor` too. Applications mint with `MintAPITokenFor`. The older `classes.MintAPIToken` is deprecated; it keeps its signature and accepts the built-ins plus every scope a catalog compiled in the same process declared, a fallback for existing callers; production runs one application per process, so that is the application's own catalog, while a test binary that boots several applications shares it (`classes.ResetDeclaredAPITokenScopes` clears it, for tests only). `user.scope:<name>`, `middleware.Scope` and `APIToken.HasScope` check a declared scope like a built-in, and `GET /tokens` lists it in `scopes`. `GET /tokens/scopes` (`jwt.auth`) answers `{"data":[{"name","label","description","built_in"}]}`, the built-ins first, then the declared scopes in activation order, with phrase-key labels and descriptions translated for the request locale. The admin `g15_` token catalog never shows these scopes (it reads `pact.HasTokenScopes` only). `classes.AllowedAPITokenScopes` stays the built-in list.
|
||||||
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the Polish templates have Polish subjects. The language follows the user's `preferred_locale`, then the request locale (the translate plugin's, else the raw `Accept-Language` header), each normalised to `pl` or `en` by `NormalizeMailLocale` (comma lists, q-weights, region subtags such as `en-GB`, case), then `golem15.user.mail.fallback_locale` (`ResolveMailLocaleFor`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
||||||
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never), so it is untrusted client input. `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `/register` logs a failing `RegisterEvent` or `CreatedEvent` listener at Error level and continues: a committed registration is never undone or failed by a listener. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
||||||
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
|
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
|
||||||
- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
|
- **OAuth providers.** `GET /oauth-providers` lists the social sign-in providers a social-login plugin has enabled, read on every request from its published `classes.OAuthProviderSource`. Without one it answers the empty list, as before.
|
||||||
@@ -257,6 +257,7 @@ Packages:
|
|||||||
- `LoginEvent` / `LoginEventName` (see Login event).
|
- `LoginEvent` / `LoginEventName` (see Login event).
|
||||||
- `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`.
|
- `DeviceShortCodeAlphabet`, `NewDeviceToken()`, `NewDeviceShortCode()`, `NormalizeDeviceShortCode(s)`, `DeviceAuthTTL(app)`, `DeviceAuthURLFor(app, token, shortCode)`, `DeviceAuthURL(app, token)` (the same with the token in place of the short code), `DeviceDisplayName(stored, userAgent)`, `RevokeDeviceSessions(ctx, db, userIDs...)` (revokes the users' pending and confirmed sessions; the plugin calls it for every `AccessRevokedEvent`), `FollowDeviceJTI(ctx, db, oldJTI, newJTI)` (moves a used session to a refreshed token's jti and reports a session revoked meanwhile; `POST /refresh` calls it and refuses the refresh then) and `HandOverDeviceJTI(ctx, db, oldJTI, newJTI)` (the same move without the report), the device authorization helpers; device tokens are hashed with `HashAPIToken`.
|
||||||
- `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook).
|
- `TwoFactorAdmin` (`HasTwoFactor(ctx, tx, userID)`, `Reset(ctx, tx, user)`) and `PermissionResetTwoFactor`, the Reset 2FA slot (see Admin: Reset 2FA), and `(*AccessRevokedEvent).PasswordChanged()` (see Password-change hook).
|
||||||
|
- `MailTemplate(base, locale)`, `ResolveMailLocale(ctx, user)`, `ResolveMailLocaleFor(ctx, app, user)`, `NormalizeMailLocale(raw)` and `MailFallbackLocale(app)`, the mail language helpers. `ResolveMailLocale` returns `pl`, `en` or `""` (neither the user nor the request names a supported language); a plugin that mails in the user's language calls `ResolveMailLocaleFor`, which adds the application's fallback and always returns `pl` or `en`, and passes its result to `MailTemplate`.
|
||||||
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
||||||
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
||||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest`, `DeviceAuthSession` (with `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired` and `DeviceStatusRevoked`) and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`) and its logo (`OrganisationLogoField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`). `User.RegisterFillable` is the registration allow-list; `User.Fillable` is the wider mass-assignment backstop of the admin Users form (cabana intersects it with the form's fields) and of trusted application code.
|
||||||
@@ -280,6 +281,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
|||||||
| `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. |
|
| `golem15.user.activation.activate_mode` | `auto` | `auto`, `user` (mailed code) or `admin`. |
|
||||||
| `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. |
|
| `golem15.user.activation.reset_ttl_minutes` | `60` | Password reset code lifetime. |
|
||||||
| `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. |
|
| `golem15.user.activation.activation_ttl_hours` | `72` | Activation code lifetime. |
|
||||||
|
| `golem15.user.mail.fallback_locale` | `pl` | The mail language when the user's `preferred_locale` and the request locale name neither `pl` nor `en`. `pl` keeps the unsuffixed templates; `en` (`SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en`) suits English-only sites. Region forms such as `en-GB` are accepted; any other value fails boot. Read on every send. The user and the request still come first, so a browser that asks for Polish first gets Polish mail on an application whose fallback is `en`. |
|
||||||
| `golem15.user.registration.allow_registration` | `true` | Whether `POST /register` accepts new accounts. |
|
| `golem15.user.registration.allow_registration` | `true` | Whether `POST /register` accepts new accounts. |
|
||||||
| `golem15.user.registration.use_register_throttle` | `true` | Whether registration is limited to three accounts per IP an hour. |
|
| `golem15.user.registration.use_register_throttle` | `true` | Whether registration is limited to three accounts per IP an hour. |
|
||||||
| `golem15.user.throttle.attempt_limit` | `5` | Failed logins before suspension. |
|
| `golem15.user.throttle.attempt_limit` | `5` | Failed logins before suspension. |
|
||||||
@@ -325,6 +327,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
|
|||||||
|
|
||||||
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
|
`TestRegisterCORSPath` reads the host application's `config/http.yaml`, so run the tests inside an application checkout where the plugin is mounted at `plugins/golem15/user`.
|
||||||
|
|
||||||
|
The mail language is pinned by `TestNormalizeMailLocale`, `TestResolveMailLocale`, `TestResolveMailLocaleFor` and `TestMailFallbackLocale` (in `classes`, `-short`), `TestMailFallbackLocaleConfig`, `TestMailFallbackLocaleBoot` and `TestPolishMailSubjects` (root, `-short`; the last renders each Polish template next to its `-en` twin and requires a different subject) and `TestMailLocaleFromRequest` (Postgres, skipped under `-short`: `POST /forgot-password` with a raw `Accept-Language` request locale, the default and an `en` fallback, and a saved `en-GB`).
|
||||||
|
|
||||||
`TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
|
`TestCommandsDocumented` (in `-short`) logs, without failing, each command whose help lacks a description, long help, examples or an option description (`bonfire.CheckDocs`).
|
||||||
|
|
||||||
`TestRegisterIgnoresProtectedFields` posts organisation, IP and account-flag keys to `/register` and `RegisterUser` and checks that only the allow-listed columns and the server-side IP are stored; `TestRegisterListenerErrors` checks that failing register and created listeners do not fail the registration.
|
`TestRegisterIgnoresProtectedFields` posts organisation, IP and account-flag keys to `/register` and `RegisterUser` and checks that only the allow-listed columns and the server-side IP are stored; `TestRegisterListenerErrors` checks that failing register and created listeners do not fail the registration.
|
||||||
|
|||||||
117
classes/mail.go
117
classes/mail.go
@@ -2,27 +2,128 @@ package classes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/towel"
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MailTemplate appends -en for English and leaves every other locale,
|
const (
|
||||||
// including empty and pl, on the unsuffixed Polish template name.
|
// mailFallbackLocaleKey names the mail language used when neither the
|
||||||
|
// user nor the request names one the plugin has mail for.
|
||||||
|
mailFallbackLocaleKey = "golem15.user.mail.fallback_locale"
|
||||||
|
// mailLocalePL is the unsuffixed (Polish) template set and the default
|
||||||
|
// fallback.
|
||||||
|
mailLocalePL = "pl"
|
||||||
|
// mailLocaleEN is the -en template set.
|
||||||
|
mailLocaleEN = "en"
|
||||||
|
)
|
||||||
|
|
||||||
|
// supportedMailLocale reports whether the plugin has mail in lang.
|
||||||
|
func supportedMailLocale(lang string) bool {
|
||||||
|
return lang == mailLocalePL || lang == mailLocaleEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeMailLocale reduces a locale value to the language the plugin has
|
||||||
|
// mail for: pl, en or "". It accepts a plain code (en, PL), a region form
|
||||||
|
// (en-GB, pl_PL) and an Accept-Language list with q-weights
|
||||||
|
// (en-GB,en;q=0.9). Entries whose q does not parse, lies outside 0..1 or is
|
||||||
|
// 0 are dropped, as are languages other than pl and en (the wildcard
|
||||||
|
// included); of the rest the one with the highest q wins, ties going to the
|
||||||
|
// earliest. The result never holds anything but pl, en or "".
|
||||||
|
func NormalizeMailLocale(raw string) string {
|
||||||
|
best, bestQ := "", -1.0
|
||||||
|
for _, entry := range strings.Split(raw, ",") {
|
||||||
|
parts := strings.Split(entry, ";")
|
||||||
|
tag := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
if i := strings.IndexAny(tag, "-_"); i >= 0 {
|
||||||
|
tag = tag[:i]
|
||||||
|
}
|
||||||
|
if !supportedMailLocale(tag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q, ok := 1.0, true
|
||||||
|
for _, param := range parts[1:] {
|
||||||
|
param = strings.TrimSpace(param)
|
||||||
|
if len(param) < 2 || !strings.EqualFold(param[:2], "q=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(param[2:]), 64)
|
||||||
|
if err != nil || v < 0 || v > 1 {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
q = v
|
||||||
|
}
|
||||||
|
if !ok || q == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q > bestQ {
|
||||||
|
best, bestQ = tag, q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailTemplate appends -en when locale normalises to English
|
||||||
|
// (NormalizeMailLocale) and leaves every other value, empty included, on the
|
||||||
|
// unsuffixed Polish template name. Callers that want the application's
|
||||||
|
// configured fallback resolve the locale with ResolveMailLocaleFor first.
|
||||||
func MailTemplate(base, locale string) string {
|
func MailTemplate(base, locale string) string {
|
||||||
if locale == "en" {
|
if NormalizeMailLocale(locale) == mailLocaleEN {
|
||||||
return base + "-en"
|
return base + "-en"
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveMailLocale prefers the user's saved locale, then the request locale.
|
// ResolveMailLocale prefers the user's saved locale, then the request locale,
|
||||||
|
// each normalised by NormalizeMailLocale; a value naming neither pl nor en is
|
||||||
|
// skipped. It returns pl, en or "" (neither names a supported language).
|
||||||
func ResolveMailLocale(ctx context.Context, user *models.User) string {
|
func ResolveMailLocale(ctx context.Context, user *models.User) string {
|
||||||
if user != nil && user.PreferredLocale != nil && *user.PreferredLocale != "" {
|
if user != nil && user.PreferredLocale != nil {
|
||||||
return *user.PreferredLocale
|
if loc := NormalizeMailLocale(*user.PreferredLocale); loc != "" {
|
||||||
}
|
|
||||||
if loc, ok := towel.Locale(ctx); ok {
|
|
||||||
return loc
|
return loc
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
if raw, ok := towel.Locale(ctx); ok {
|
||||||
|
return NormalizeMailLocale(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MailFallbackLocale returns golem15.user.mail.fallback_locale normalised to
|
||||||
|
// pl or en: the mail language when neither the user nor the request names
|
||||||
|
// one the plugin has mail for. The default is pl (the unsuffixed
|
||||||
|
// templates); an English-only site sets en, for example through
|
||||||
|
// SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en. A missing app, config or
|
||||||
|
// blank value gives pl; any other value gives pl and an error naming the key.
|
||||||
|
func MailFallbackLocale(app *backpack.App) (string, error) {
|
||||||
|
if app == nil || app.Config == nil {
|
||||||
|
return mailLocalePL, nil
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(app.Config.String(mailFallbackLocaleKey))
|
||||||
|
if raw == "" {
|
||||||
|
return mailLocalePL, nil
|
||||||
|
}
|
||||||
|
if loc := NormalizeMailLocale(raw); loc != "" {
|
||||||
|
return loc, nil
|
||||||
|
}
|
||||||
|
return mailLocalePL, fmt.Errorf("golem15.user: %s must be pl or en, got %q", mailFallbackLocaleKey, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveMailLocaleFor returns the language to mail user in, always pl or
|
||||||
|
// en: ResolveMailLocale, then the application's MailFallbackLocale. It is the
|
||||||
|
// helper other plugins use to mail in the user's language with the
|
||||||
|
// application's fallback; an invalid fallback (rejected at boot) counts as pl.
|
||||||
|
func ResolveMailLocaleFor(ctx context.Context, app *backpack.App, user *models.User) string {
|
||||||
|
if loc := ResolveMailLocale(ctx, user); loc != "" {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
loc, _ := MailFallbackLocale(app)
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,187 @@
|
|||||||
package classes
|
package classes
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
)
|
||||||
|
|
||||||
func TestMailTemplate(t *testing.T) {
|
func TestMailTemplate(t *testing.T) {
|
||||||
base := "golem15.user::mail.reactivate"
|
base := "golem15.user::mail.reactivate"
|
||||||
if got := MailTemplate(base, "pl"); got != base {
|
for _, tc := range []struct {
|
||||||
t.Fatalf("pl = %s", got)
|
locale, want string
|
||||||
|
}{
|
||||||
|
{"pl", base},
|
||||||
|
{"", base},
|
||||||
|
{"en", base + "-en"},
|
||||||
|
{"en-GB", base + "-en"},
|
||||||
|
{"en-GB,en;q=0.9", base + "-en"},
|
||||||
|
{"de", base},
|
||||||
|
{"pl-PL,en;q=0.5", base},
|
||||||
|
} {
|
||||||
|
if got := MailTemplate(base, tc.locale); got != tc.want {
|
||||||
|
t.Errorf("MailTemplate(%q) = %s, want %s", tc.locale, got, tc.want)
|
||||||
}
|
}
|
||||||
if got := MailTemplate(base, ""); got != base {
|
}
|
||||||
t.Fatalf("empty = %s", got)
|
}
|
||||||
}
|
|
||||||
if got := MailTemplate(base, "en"); got != base+"-en" {
|
func TestNormalizeMailLocale(t *testing.T) {
|
||||||
t.Fatalf("en = %s", got)
|
for _, tc := range []struct {
|
||||||
|
raw, want string
|
||||||
|
}{
|
||||||
|
{"en", "en"},
|
||||||
|
{"pl", "pl"},
|
||||||
|
{"EN", "en"},
|
||||||
|
{"en-GB", "en"},
|
||||||
|
{"en_US", "en"},
|
||||||
|
{"pl-PL", "pl"},
|
||||||
|
{" en-gb ; q=0.9 ", "en"},
|
||||||
|
{"en-GB,en;q=0.9", "en"},
|
||||||
|
{"pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7", "pl"},
|
||||||
|
{"de-DE,de;q=0.9,en;q=0.8", "en"},
|
||||||
|
{"de-DE,en;q=0.5,pl;q=0.7", "pl"},
|
||||||
|
{"en;q=0.5,pl;q=0.5", "en"},
|
||||||
|
{"en;q=0,pl", "pl"},
|
||||||
|
{"en;q=0", ""},
|
||||||
|
{"en;q=abc,pl;q=0.1", "pl"},
|
||||||
|
{"en;q=1.5", ""},
|
||||||
|
{"english", ""},
|
||||||
|
{"de", ""},
|
||||||
|
{"*", ""},
|
||||||
|
{"", ""},
|
||||||
|
{" ", ""},
|
||||||
|
} {
|
||||||
|
if got := NormalizeMailLocale(tc.raw); got != tc.want {
|
||||||
|
t.Errorf("NormalizeMailLocale(%q) = %q, want %q", tc.raw, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMailLocale(t *testing.T) {
|
||||||
|
str := func(s string) *string { return &s }
|
||||||
|
header := "en-GB,en;q=0.9"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
saved *string
|
||||||
|
nilUser bool
|
||||||
|
request string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"saved region form wins", str("en-GB"), false, "pl", "en"},
|
||||||
|
{"saved upper case", str("PL"), false, "", "pl"},
|
||||||
|
{"unsupported saved falls through", str("de"), false, header, "en"},
|
||||||
|
{"nil user, raw header", nil, true, header, "en"},
|
||||||
|
{"nil user, no request locale", nil, true, "", ""},
|
||||||
|
{"empty saved, unsupported request", str(""), false, "fr", ""},
|
||||||
|
} {
|
||||||
|
ctx := context.Background()
|
||||||
|
if tc.request != "" {
|
||||||
|
ctx = towel.WithLocale(ctx, tc.request)
|
||||||
|
}
|
||||||
|
var user *models.User
|
||||||
|
if !tc.nilUser {
|
||||||
|
user = &models.User{PreferredLocale: tc.saved}
|
||||||
|
}
|
||||||
|
if got := ResolveMailLocale(ctx, user); got != tc.want {
|
||||||
|
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailTestApp(t *testing.T, environ ...string) *backpack.App {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Env: "development", Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return backpack.New(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMailFallbackLocale(t *testing.T) {
|
||||||
|
if got, err := MailFallbackLocale(nil); got != "pl" || err != nil {
|
||||||
|
t.Errorf("nil app: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := MailFallbackLocale(&backpack.App{}); got != "pl" || err != nil {
|
||||||
|
t.Errorf("nil config: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := MailFallbackLocale(mailTestApp(t)); got != "pl" || err != nil {
|
||||||
|
t.Errorf("no key: %q %v", got, err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
value, want string
|
||||||
|
bad bool
|
||||||
|
}{
|
||||||
|
{"", "pl", false},
|
||||||
|
{" ", "pl", false},
|
||||||
|
{"en", "en", false},
|
||||||
|
{"EN-gb", "en", false},
|
||||||
|
{"pl", "pl", false},
|
||||||
|
{"de", "pl", true},
|
||||||
|
} {
|
||||||
|
app := mailTestApp(t)
|
||||||
|
if err := app.Config.Set("golem15.user.mail.fallback_locale", tc.value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := MailFallbackLocale(app)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("%q: got %q, want %q", tc.value, got, tc.want)
|
||||||
|
}
|
||||||
|
if tc.bad {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "golem15.user.mail.fallback_locale") || !strings.Contains(err.Error(), `"de"`) {
|
||||||
|
t.Errorf("%q: error %v", tc.value, err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("%q: unexpected error %v", tc.value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app := mailTestApp(t, "SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en")
|
||||||
|
if got, err := MailFallbackLocale(app); got != "en" || err != nil {
|
||||||
|
t.Errorf("env override: %q %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMailLocaleFor(t *testing.T) {
|
||||||
|
const restore = "golem15.user::mail.restore"
|
||||||
|
user := &models.User{}
|
||||||
|
headerCtx := towel.WithLocale(context.Background(), "en-GB,en;q=0.9")
|
||||||
|
|
||||||
|
// The reported bug: raw Accept-Language, no preferred_locale, default config.
|
||||||
|
if got := MailTemplate(restore, ResolveMailLocaleFor(headerCtx, mailTestApp(t), user)); got != restore+"-en" {
|
||||||
|
t.Errorf("raw header: %s", got)
|
||||||
|
}
|
||||||
|
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), mailTestApp(t), user)); got != restore {
|
||||||
|
t.Errorf("default fallback: %s", got)
|
||||||
|
}
|
||||||
|
if got := ResolveMailLocaleFor(context.Background(), nil, nil); got != "pl" {
|
||||||
|
t.Errorf("nil app and user: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
set := mailTestApp(t)
|
||||||
|
if err := set.Config.Set("golem15.user.mail.fallback_locale", "en"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), set, user)); got != restore+"-en" {
|
||||||
|
t.Errorf("fallback en via Set: %s", got)
|
||||||
|
}
|
||||||
|
env := mailTestApp(t, "SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en")
|
||||||
|
if got := MailTemplate(restore, ResolveMailLocaleFor(context.Background(), env, user)); got != restore+"-en" {
|
||||||
|
t.Errorf("fallback en via env: %s", got)
|
||||||
|
}
|
||||||
|
// The user and the request still come first.
|
||||||
|
if got := ResolveMailLocaleFor(towel.WithLocale(context.Background(), "pl-PL,en;q=0.5"), set, user); got != "pl" {
|
||||||
|
t.Errorf("Polish-first browser on an en fallback: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := mailTestApp(t)
|
||||||
|
if err := bad.Config.Set("golem15.user.mail.fallback_locale", "de"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := ResolveMailLocaleFor(context.Background(), bad, user); got != "pl" {
|
||||||
|
t.Errorf("invalid fallback: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ activation:
|
|||||||
activate_mode: auto
|
activate_mode: auto
|
||||||
reset_ttl_minutes: 60
|
reset_ttl_minutes: 60
|
||||||
activation_ttl_hours: 72
|
activation_ttl_hours: 72
|
||||||
|
mail:
|
||||||
|
# The language of this plugin's mails when neither the user's
|
||||||
|
# preferred_locale nor the request locale (the translate plugin's locale,
|
||||||
|
# else the Accept-Language header) names one the plugin has mail for (pl or
|
||||||
|
# en). pl keeps the unsuffixed Polish templates; an English-only site sets
|
||||||
|
# en (SUMMER_GOLEM15__USER__MAIL__FALLBACK_LOCALE=en). Any other value
|
||||||
|
# fails boot.
|
||||||
|
fallback_locale: pl
|
||||||
registration:
|
registration:
|
||||||
allow_registration: true
|
allow_registration: true
|
||||||
use_register_throttle: true
|
use_register_throttle: true
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func Login(app *backpack.App) http.HandlerFunc {
|
|||||||
writeOpaque500(w)
|
writeOpaque500(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||||
}
|
}
|
||||||
// A failed last_seen write is logged and never fails the login.
|
// A failed last_seen write is logged and never fails the login.
|
||||||
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
|
if err := classes.TouchLastSeen(ctx, gdb, user.ID); err != nil {
|
||||||
@@ -476,7 +476,7 @@ func ForgotPassword(app *backpack.App) http.HandlerFunc {
|
|||||||
pluginLog.Error("golem15.user: issue reset code", "err", err)
|
pluginLog.Error("golem15.user: issue reset code", "err", err)
|
||||||
} else {
|
} else {
|
||||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
|
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.restore"), user.Email, mailVars(displayName(user), resetLink(app, user.ID, code), packed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
writeJSON(w, http.StatusOK, map[string]any{"message": forgotPasswordBody})
|
||||||
@@ -937,7 +937,7 @@ func Register(app *backpack.App) http.HandlerFunc {
|
|||||||
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
packed := strconv.FormatUint(uint64(user.ID), 10) + "!" + code
|
||||||
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
|
vars = mailVars(displayName(user), activationLink(app, user.ID, code), packed)
|
||||||
}
|
}
|
||||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.activate"), user.Email, vars)
|
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.activate"), user.Email, vars)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
|
writeJSON(w, http.StatusOK, map[string]any{"message": "Activation email sent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1157,8 +1157,8 @@ func blacklistGrace(app *backpack.App) time.Duration {
|
|||||||
return time.Duration(secs) * time.Second
|
return time.Duration(secs) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
func mailName(ctx context.Context, user *models.User, base string) string {
|
func mailName(ctx context.Context, app *backpack.App, user *models.User, base string) string {
|
||||||
return classes.MailTemplate(base, classes.ResolveMailLocale(ctx, user))
|
return classes.MailTemplate(base, classes.ResolveMailLocaleFor(ctx, app, user))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {
|
func sendTemplate(ctx context.Context, app *backpack.App, template, to string) {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func FinishLogin(ctx context.Context, app *backpack.App, db *gorm.DB, user *mode
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
user.DeletedAt = gorm.DeletedAt{}
|
user.DeletedAt = gorm.DeletedAt{}
|
||||||
sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
sendTemplateVars(ctx, app, mailName(ctx, app, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", ""))
|
||||||
}
|
}
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {
|
if err := db.WithContext(ctx).Unscoped().Model(user).Update("last_ip_address", ip).Error; err != nil {
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) er
|
|||||||
vars["login"], vars["Login"] = email, email
|
vars["login"], vars["Login"] = email, email
|
||||||
hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72)
|
hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72)
|
||||||
vars["hours"], vars["Hours"] = hours, hours
|
vars["hours"], vars["Hours"] = hours, hours
|
||||||
template := mailName(ctx, user, "golem15.user::mail.invite")
|
template := mailName(ctx, app, user, "golem15.user::mail.invite")
|
||||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||||
sendTemplateVars(ctx, app, template, email, vars)
|
sendTemplateVars(ctx, app, template, email, vars)
|
||||||
})
|
})
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -3,20 +3,20 @@ module git.golem15.com/golem15/sm-user-plugin
|
|||||||
go 1.27.0
|
go 1.27.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.golem15.com/golem15/summercms v0.5.0
|
git.golem15.com/golem15/summercms v0.5.2
|
||||||
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
github.com/go-gormigrate/gormigrate/v2 v2.1.7
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/testcontainers/testcontainers-go v0.44.0
|
github.com/testcontainers/testcontainers-go v0.44.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
|
||||||
gocloud.dev v0.46.0
|
gocloud.dev v0.46.0
|
||||||
|
gorm.io/driver/postgres v1.6.3
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
charm.land/bubbles/v2 v2.2.1 // indirect
|
charm.land/bubbles/v2 v2.2.1 // indirect
|
||||||
charm.land/bubbletea/v2 v2.1.0 // indirect
|
charm.land/bubbletea/v2 v2.1.0 // indirect
|
||||||
charm.land/fang/v2 v2.0.1 // indirect
|
|
||||||
charm.land/huh/v2 v2.0.3 // indirect
|
charm.land/huh/v2 v2.0.3 // indirect
|
||||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||||
dario.cat/mergo v1.0.2 // indirect
|
dario.cat/mergo v1.0.2 // indirect
|
||||||
@@ -50,7 +50,6 @@ require (
|
|||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
|
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
@@ -120,10 +119,6 @@ require (
|
|||||||
github.com/moby/term v0.5.2 // indirect
|
github.com/moby/term v0.5.2 // indirect
|
||||||
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/mango v0.1.0 // indirect
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 // indirect
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 // indirect
|
|
||||||
github.com/muesli/roff v0.1.0 // indirect
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
@@ -177,7 +172,6 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
|
||||||
google.golang.org/grpc v1.79.3 // indirect
|
google.golang.org/grpc v1.79.3 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gorm.io/driver/postgres v1.6.3 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
replace git.golem15.com/golem15/summercms => ../summercms.go
|
replace git.golem15.com/golem15/summercms => ../summercms.go
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -4,8 +4,6 @@ charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
|||||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||||
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
|
|
||||||
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
|
|
||||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||||
@@ -110,8 +108,6 @@ github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4
|
|||||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
|
|
||||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
|
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||||
@@ -299,14 +295,6 @@ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtw
|
|||||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
|
|
||||||
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
|
|
||||||
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
|
|
||||||
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
|
|
||||||
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
|
|
||||||
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
|||||||
114
mail_locale_test.go
Normal file
114
mail_locale_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/towel"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mailFallbackKey = "golem15.user.mail.fallback_locale"
|
||||||
|
|
||||||
|
// TestMailFallbackLocaleConfig reads golem15.user.mail.fallback_locale from
|
||||||
|
// the plugin's config.yaml default and from overrides.
|
||||||
|
func TestMailFallbackLocaleConfig(t *testing.T) {
|
||||||
|
app := bareApp(t)
|
||||||
|
if err := app.Config.MergePlugin("golem15.user", configFS); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v, ok := app.Config.Lookup(mailFallbackKey); !ok || v != "pl" {
|
||||||
|
t.Fatalf("config.yaml default = %#v (set %v), want pl", v, ok)
|
||||||
|
}
|
||||||
|
if got, err := classes.MailFallbackLocale(app); got != "pl" || err != nil {
|
||||||
|
t.Fatalf("default: %q %v", got, err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ value, want string }{{"en", "en"}, {"EN-gb", "en"}} {
|
||||||
|
if err := app.Config.Set(mailFallbackKey, tc.value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := classes.MailFallbackLocale(app); got != tc.want || err != nil {
|
||||||
|
t.Errorf("%q: %q %v", tc.value, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := app.Config.Set(mailFallbackKey, "de"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := classes.MailFallbackLocale(app); err == nil || !strings.Contains(err.Error(), mailFallbackKey) {
|
||||||
|
t.Errorf("de: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMailFallbackLocaleBoot fails Boot on an unsupported fallback before
|
||||||
|
// anything is started, and leaves an unset key alone.
|
||||||
|
func TestMailFallbackLocaleBoot(t *testing.T) {
|
||||||
|
withSecret := func(t *testing.T) *backpack.App {
|
||||||
|
app := bareApp(t)
|
||||||
|
if err := app.Config.Set("golem15.user.jwt.secret", "mail-locale-boot-secret"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
app := withSecret(t)
|
||||||
|
if err := app.Config.Set(mailFallbackKey, "de"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := (&Plugin{}).Boot(app); err == nil || !strings.Contains(err.Error(), mailFallbackKey) {
|
||||||
|
t.Fatalf("Boot with fallback de: %v", err)
|
||||||
|
}
|
||||||
|
if err := (&Plugin{}).Boot(withSecret(t)); err != nil && strings.Contains(err.Error(), mailFallbackKey) {
|
||||||
|
t.Fatalf("Boot with the key unset: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMailLocaleFromRequest sends the reset mail through POST
|
||||||
|
// /forgot-password with the request locale the router stores when no
|
||||||
|
// translate plugin is installed: the raw Accept-Language header.
|
||||||
|
func TestMailLocaleFromRequest(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Postgres (testcontainers)")
|
||||||
|
}
|
||||||
|
app, gdb := sessionApp(t, "0s")
|
||||||
|
rec, _ := publishMemoryMail(t, app)
|
||||||
|
|
||||||
|
forgot := func(t *testing.T, email, locale string) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/forgot-password", strings.NewReader(`{"email":"`+email+`"}`))
|
||||||
|
req.Host = "example.com"
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if locale != "" {
|
||||||
|
req = req.WithContext(towel.WithLocale(req.Context(), locale))
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
controllers.ForgotPassword(app)(w, req)
|
||||||
|
if w.Code >= 300 {
|
||||||
|
t.Fatalf("forgot-password %s: %d %s", email, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertUser(t, gdb, "locale-header@example.test", "correct-horse")
|
||||||
|
forgot(t, "locale-header@example.test", "en-GB,en;q=0.9")
|
||||||
|
assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-header@example.test")
|
||||||
|
|
||||||
|
insertUser(t, gdb, "locale-none@example.test", "correct-horse")
|
||||||
|
forgot(t, "locale-none@example.test", "")
|
||||||
|
assertTemplate(t, rec, "golem15.user::mail.restore", "locale-none@example.test")
|
||||||
|
|
||||||
|
if err := app.Config.Set(mailFallbackKey, "en"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
insertUser(t, gdb, "locale-fallback@example.test", "correct-horse")
|
||||||
|
forgot(t, "locale-fallback@example.test", "")
|
||||||
|
assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-fallback@example.test")
|
||||||
|
|
||||||
|
saved := insertUser(t, gdb, "locale-saved@example.test", "correct-horse")
|
||||||
|
if err := gdb.Model(saved).Update("preferred_locale", "en-GB").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forgot(t, "locale-saved@example.test", "pl")
|
||||||
|
assertTemplate(t, rec, "golem15.user::mail.restore-en", "locale-saved@example.test")
|
||||||
|
}
|
||||||
58
mail_subjects_test.go
Normal file
58
mail_subjects_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/modules/postcard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPolishMailSubjects renders every Polish template next to its -en twin
|
||||||
|
// and requires a subject of its own, so a Polish template with a copied
|
||||||
|
// English subject does not slip through.
|
||||||
|
func TestPolishMailSubjects(t *testing.T) {
|
||||||
|
p := &Plugin{}
|
||||||
|
cat := postcard.NewCatalog()
|
||||||
|
if err := cat.Register(p.ID(), p.MailTemplatesFS(), p.MailTemplates(), p.MailLayouts()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, name := range p.MailTemplates() {
|
||||||
|
names[name] = true
|
||||||
|
}
|
||||||
|
vars := map[string]any{
|
||||||
|
"name": "Ada", "Name": "Ada",
|
||||||
|
"link": "https://example.com/link", "Link": "https://example.com/link",
|
||||||
|
"code": "1!abc", "Code": "1!abc",
|
||||||
|
"hours": 72, "Hours": 72,
|
||||||
|
"login": "ada@example.test", "Login": "ada@example.test",
|
||||||
|
}
|
||||||
|
subject := func(t *testing.T, template string) string {
|
||||||
|
t.Helper()
|
||||||
|
mem := postcard.NewMemoryDriver()
|
||||||
|
mailer := postcard.NewMailer(cat, mem, postcard.Options{From: "noreply@example.test"})
|
||||||
|
if err := mailer.Send(context.Background(), postcard.Message{Template: template, To: []string{"ada@example.test"}, Vars: vars}); err != nil {
|
||||||
|
t.Fatalf("%s: %v", template, err)
|
||||||
|
}
|
||||||
|
msgs := mem.Messages()
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("%s: %d messages", template, len(msgs))
|
||||||
|
}
|
||||||
|
return msgs[0].Subject
|
||||||
|
}
|
||||||
|
pairs := 0
|
||||||
|
for _, name := range p.MailTemplates() {
|
||||||
|
if strings.HasSuffix(name, "-en") || !names[name+"-en"] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs++
|
||||||
|
pl, en := subject(t, name), subject(t, name+"-en")
|
||||||
|
if pl == "" || pl == en {
|
||||||
|
t.Errorf("%s: Polish subject %q, English subject %q", name, pl, en)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pairs == 0 {
|
||||||
|
t.Fatal("no Polish/English template pairs")
|
||||||
|
}
|
||||||
|
}
|
||||||
2
mcp.go
2
mcp.go
@@ -15,7 +15,7 @@ func (p *Plugin) MCP() pact.MCP {
|
|||||||
return pact.MCP{Tools: []pact.MCPTool{{
|
return pact.MCP{Tools: []pact.MCPTool{{
|
||||||
Name: "me",
|
Name: "me",
|
||||||
Title: "Current user",
|
Title: "Current user",
|
||||||
Description: "Returns the profile of the signed-in frontend user.",
|
Description: "Returns the profile of the signed-in frontend user. Needs scope read.",
|
||||||
Guard: "user",
|
Guard: "user",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/_user/api/v1/token/me",
|
Path: "/_user/api/v1/token/me",
|
||||||
|
|||||||
@@ -86,14 +86,15 @@ func (g *APITokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
|
|||||||
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
|
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Invalid token."})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scope requires one delegated scope from the shared token credential.
|
// Scope requires one delegated scope from the shared token credential. A
|
||||||
|
// refusal answers 403 with error, message and missing_scope naming the scope.
|
||||||
func Scope(scope string) pact.Middleware {
|
func Scope(scope string) pact.Middleware {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
credential, _ := bouncer.Credential(r.Context())
|
credential, _ := bouncer.Credential(r.Context())
|
||||||
token, ok := credential.(*models.APIToken)
|
token, ok := credential.(*models.APIToken)
|
||||||
if !ok || token == nil || !token.HasScope(scope) {
|
if !ok || token == nil || !token.HasScope(scope) {
|
||||||
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope})
|
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Missing required scope: " + scope, "missing_scope": scope})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -103,12 +104,14 @@ func Scope(scope string) pact.Middleware {
|
|||||||
|
|
||||||
// Permission requires one current user-group permission. Tokens never carry
|
// Permission requires one current user-group permission. Tokens never carry
|
||||||
// a permission snapshot: revoking the user's grant takes effect immediately.
|
// a permission snapshot: revoking the user's grant takes effect immediately.
|
||||||
|
// A refusal answers 403 with error, message and missing_permission naming the
|
||||||
|
// code.
|
||||||
func Permission(code string) pact.Middleware {
|
func Permission(code string) pact.Middleware {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
principal, _ := bouncer.User(r.Context())
|
principal, _ := bouncer.User(r.Context())
|
||||||
if !cabana.Allows(principal, []string{code}) {
|
if !cabana.Allows(principal, []string{code}) {
|
||||||
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden."})
|
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Forbidden.", "missing_permission": code})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
|
|||||||
88
middleware/api_token_test.go
Normal file
88
middleware/api_token_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forbiddenBody is the 403 body of Scope and Permission.
|
||||||
|
type forbiddenBody struct {
|
||||||
|
Error bool `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
MissingScope string `json:"missing_scope"`
|
||||||
|
MissingPermission string `json:"missing_permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenWith is an in-memory credential holding scopes, built the way
|
||||||
|
// MCPDispatch builds it.
|
||||||
|
func tokenWith(scopes ...string) *models.APIToken {
|
||||||
|
return &models.APIToken{Scopes: lagoon.Jsonable[[]string]{Data: scopes, Valid: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve runs mw over a handler answering 200, with ctx on the request.
|
||||||
|
func serve(t *testing.T, mw func(http.Handler) http.Handler, ctx context.Context) (int, forbiddenBody) {
|
||||||
|
t.Helper()
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||||
|
mw(next).ServeHTTP(rec, req)
|
||||||
|
var body forbiddenBody
|
||||||
|
if rec.Code == http.StatusForbidden {
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("403 body %q: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rec.Code, body
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForbiddenBodiesNameWhatIsMissing (D-15, D-20): a refusal by Scope or
|
||||||
|
// Permission keeps error true and the message, and adds missing_scope or
|
||||||
|
// missing_permission naming what is missing. No database.
|
||||||
|
func TestForbiddenBodiesNameWhatIsMissing(t *testing.T) {
|
||||||
|
scopeRefused := forbiddenBody{Error: true, Message: "Missing required scope: write", MissingScope: "write"}
|
||||||
|
|
||||||
|
t.Run("scope missing from the credential", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
|
||||||
|
code, body := serve(t, Scope("write"), ctx)
|
||||||
|
if code != http.StatusForbidden || body != scopeRefused {
|
||||||
|
t.Fatalf("Scope(write) with read = %d %+v, want 403 %+v", code, body, scopeRefused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no credential", func(t *testing.T) {
|
||||||
|
code, body := serve(t, Scope("write"), context.Background())
|
||||||
|
if code != http.StatusForbidden || body != scopeRefused {
|
||||||
|
t.Fatalf("Scope(write) without a credential = %d %+v, want 403 %+v", code, body, scopeRefused)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scope held", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithCredential(context.Background(), tokenWith("read"))
|
||||||
|
if code, _ := serve(t, Scope("read"), ctx); code != http.StatusOK {
|
||||||
|
t.Fatalf("Scope(read) with read = %d, want 200", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("permission missing", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_schedule": true}})
|
||||||
|
want := forbiddenBody{Error: true, Message: "Forbidden.", MissingPermission: "jz.bm.access_courses"}
|
||||||
|
code, body := serve(t, Permission("jz.bm.access_courses"), ctx)
|
||||||
|
if code != http.StatusForbidden || body != want {
|
||||||
|
t.Fatalf("Permission without the grant = %d %+v, want 403 %+v", code, body, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("permission granted", func(t *testing.T) {
|
||||||
|
ctx := bouncer.WithUser(context.Background(), &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"jz.bm.access_courses": true}})
|
||||||
|
if code, _ := serve(t, Permission("jz.bm.access_courses"), ctx); code != http.StatusOK {
|
||||||
|
t.Fatalf("Permission with the grant = %d, want 200", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -76,6 +76,9 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := classes.MailFallbackLocale(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Serve publishes the database after Boot, so the GORM hooks, the
|
// Serve publishes the database after Boot, so the GORM hooks, the
|
||||||
// blacklist store and its sweep are installed on lagoon.Publish, or at
|
// blacklist store and its sweep are installed on lagoon.Publish, or at
|
||||||
// once when the database is already published.
|
// once when the database is already published.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
subject = "Activate your account"
|
subject = "Aktywuj konto"
|
||||||
description = "Aktywacja konta"
|
description = "Aktywacja konta"
|
||||||
layout = "user"
|
layout = "user"
|
||||||
==
|
==
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
subject = "Welcome back"
|
subject = "Witaj ponownie"
|
||||||
description = "Konto zostało ponownie aktywowane"
|
description = "Konto zostało ponownie aktywowane"
|
||||||
layout = "user"
|
layout = "user"
|
||||||
==
|
==
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
subject = "Reset your password"
|
subject = "Ustaw nowe hasło"
|
||||||
description = "Reset hasła"
|
description = "Reset hasła"
|
||||||
layout = "user"
|
layout = "user"
|
||||||
==
|
==
|
||||||
|
|||||||
Reference in New Issue
Block a user