From f20a8d3b98d3bafa5a58bcacc54ebbd427affa45 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 17:03:13 +0200 Subject: [PATCH] feat: organisation logo (attachOne logo) and payload helpers - Organisation gains the public attachOne logo (OrganisationLogoField), editable as an image upload on the Organisations form; avatar unchanged - OrganisationLogoURL and OrganisationLogoFields give applications the has_logo, logo and logo_url keys for their organisation payloads - no migration: attachments live in system_files --- README.md | 7 +-- admin_organisations_test.go | 6 ++- classes/organisation_logo.go | 90 +++++++++++++++++++++++++++++++++ lang/en/lang.yaml | 2 + lang/pl/lang.yaml | 2 + models/admin_models_test.go | 9 ++-- models/organisation.go | 10 +++- models/organisation/fields.yaml | 7 +++ 8 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 classes/organisation_logo.go diff --git a/README.md b/README.md index 8e2f6be..437b75d 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap - **Rate limiting.** Every route runs behind the `user-api` bucket: 120 requests a minute per authenticated user, or per client IP for anonymous calls. - **Profile.** `POST /update`, `POST /marketing-consent`, `POST /avatar` and `POST /avatar/remove` (the avatar is stored through the framework's attachment storage). - **Deactivation and bans.** Deactivation is self-service and reversible: a password login or a `golem15.sociallogin` sign-in restores a deactivated account and sends the reactivate mail, and an administrator can restore it too. It ends every earlier session JWT and personal API token of the user (`RevokeUserAccess` stamps `tokens_valid_after` and revokes the `user_api_tokens` rows), so the user signs in again and gets a fresh token, while the old API tokens stay revoked. A ban is the lock: password login, social sign-in and `POST /refresh` refuse a banned user, and banning revokes the user's sessions and API tokens. A banned user's account link answers no administrator (`LinkedBackendUser`). Neither unban nor restore revives a token. Activation gates login, deactivation does not: while `require_activation` is on, an account that is both deactivated and not activated is refused and stays deactivated, with no reactivate mail. Upgrade note: accounts deactivated or banned before this version keep their API tokens until they are deactivated or banned again (both actions are idempotent) or `RevokeUserAccess` runs for them. -- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar and its members: the users whose `organisation_id` points at it. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it. +- **Organisations.** The `Organisation` model and the `organisation_id` and `organisation_role` user columns, as a foreign-key target for other plugins. An organisation has a name, a slug (filled from the name by `Slugify` when it is left empty), a description, an avatar, a logo and its members: the users whose `organisation_id` points at it. The logo is a second public image attachment (`logo`, `models.OrganisationLogoField`), kept in `system_files` like the avatar. The plugin serves no frontend organisation payload, so applications that build one add the logo with `classes.OrganisationLogoFields(ctx, app, org)`, which answers `has_logo` (bool), `logo` (the attachment's disk name, or null) and `logo_url` (a 256 px thumbnail URL, `classes.OrganisationLogoSize`, or null), the keys the user payload uses for its avatar; `classes.OrganisationLogoURL(ctx, app, org, size)` answers the URL at any size and false when there is no logo or no file bucket. `organisation_role` belongs to that membership: the admin clears it whenever a user's organisation changes (see the Organisations screen), and an application that sets `organisation_id` itself sets or clears the role with it. - **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group. - **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account. - **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group. -- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description and avatar, and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first. +- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description, avatar and logo (an image upload), and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first. - **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code. - **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`. - **Last seen.** `users.last_seen` is stamped by `POST /login` and a successful `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload. @@ -228,13 +228,14 @@ Packages: - `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error. - `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none); `SessionTokenCookie(r)`, the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie; and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token. - `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but a blacklist failure is logged, never returned. + - `OrganisationLogoURL(ctx, app, org, size)`, `OrganisationLogoFields(ctx, app, org)` and `OrganisationLogoSize`, the organisation logo for an application's organisation payloads (see Organisations). - `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128. - `BackendLinks` (`CreateLinkRequest`, `LinkRequestByCode`, `ConfirmLinkRequest`, `SetLink`, `RemoveLink`, `LinkFor`, `LinkOf`, `LinkedBackendUser`, `RegisterBackendLinkListeners`), its errors `ErrLinkRequestNotFound`, `ErrLinkRequestUsed`, `ErrLinkRequestExpired`, `ErrStaleSession`, `ErrAlreadyLinked`, `ErrBackendUserUnavailable` and `ErrFrontendUserUnavailable`, the events `BackendLinkedEvent` / `BackendLinkedEventName` and `BackendUnlinkedEvent` / `BackendUnlinkedEventName`, `BackendLinkRequestTTL(app)`, `DefaultBackendLinkRequestTTL`, `FrontendURL(app)`, `BackendLinkPagePath(app)`, `DefaultBackendLinkPagePath`, `BackendLinksFor(app)` (the published service, or one over the app) and `BackendDisplayName(admin)`. - `PermissionManageBackendLinks`, `FrontendPermissionCatalog(ctx, app, db)`, `MCPUserGuard`, `FireSubjectChanged(ctx, app, guard, userID, kind)` and `FireSubjectChangedAfterCommit(ctx, app, tx, guard, userID, kind)` with the guard names `SubjectGuardUser` and `SubjectGuardBackend`, and `FireSubjectEvent(ctx, app, ev)` and `FireSubjectEventAfterCommit(ctx, app, tx, ev)`, which fire a whole `pact.MCPSubjectChanged` (with `TokenID` or `ClientID` to end only one token's or one client's sessions). - `AccessRevokedEvent` / `AccessRevokedEventName` and `FireAccessRevoked(ctx, app, tx, userIDs, before, reason)` (see Access revocation). - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. - `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`. -- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission`, `BackendLink`, `BackendLinkRequest` 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 `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` 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. - `updates`: the gormigrate migrations, returned by `updates.All()`. - `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`. diff --git a/admin_organisations_test.go b/admin_organisations_test.go index 4c48078..0a6c87b 100644 --- a/admin_organisations_test.go +++ b/admin_organisations_test.go @@ -156,13 +156,17 @@ func TestAdminOrganisations(t *testing.T) { if field.Mode != "image" || field.ImageWidth != 120 || field.ImageHeight != 120 { t.Fatalf("avatar field = %+v", field) } + case "logo": + if field.Mode != "image" || field.ImageWidth != 240 || field.ImageHeight != 120 { + t.Fatalf("logo field = %+v", field) + } case "members": if field.Context != "update" || field.Tab != "Members" { t.Fatalf("members field = %+v", field) } } } - if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "members:relation-manager"}; !reflect.DeepEqual(names, want) { + if want := []string{"name:text", "slug:text", "description:textarea", "avatar:fileupload", "logo:fileupload", "members:relation-manager"}; !reflect.DeepEqual(names, want) { t.Fatalf("form fields = %v, want %v", names, want) } if len(form.Preview) != 0 && string(form.Preview) != "null" { diff --git a/classes/organisation_logo.go b/classes/organisation_logo.go new file mode 100644 index 0000000..b9785bb --- /dev/null +++ b/classes/organisation_logo.go @@ -0,0 +1,90 @@ +package classes + +import ( + "context" + "errors" + "strconv" + + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gocloud.dev/blob" + "gorm.io/gorm" +) + +// OrganisationLogoSize is the edge in pixels of the logo thumbnail +// OrganisationLogoFields answers. +const OrganisationLogoSize = 256 + +// organisationLogo loads the organisation's logo attachment, or nil. +func organisationLogo(ctx context.Context, app *backpack.App, org *models.Organisation) (*attach.File, error) { + if app == nil || org == nil || org.ID == 0 { + return nil, nil + } + gdb, ok := app.Lookup[*gorm.DB]() + if !ok || gdb == nil { + return nil, nil + } + var f attach.File + err := gdb.WithContext(ctx). + Where("attachment_type = ? AND attachment_id = ? AND field = ?", org.MorphName(), strconv.FormatUint(uint64(org.ID), 10), models.OrganisationLogoField). + Take(&f).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &f, nil +} + +// OrganisationLogoURL returns the URL of the organisation's logo at size +// pixels, the way AvatarURL serves a user's picture: the thumbnail (size by +// size, mode auto) of the logo attachment, or the attachment's file path +// when the thumbnail fails (logged). It answers false when the organisation +// has no logo or no file bucket is published. The URL may be relative; a +// caller that needs an absolute URL resolves it. Only a failing database +// lookup is an error. +func OrganisationLogoURL(ctx context.Context, app *backpack.App, org *models.Organisation, size int) (string, bool, error) { + f, err := organisationLogo(ctx, app, org) + if err != nil || f == nil { + return "", false, err + } + bucket, ok := app.Lookup[*blob.Bucket]() + if !ok || bucket == nil { + return "", false, nil + } + url, err := f.Thumb(ctx, bucket, size, size, "auto") + if err != nil { + pluginLog.Error("golem15.user: organisation logo thumb", "err", err) + return "/" + attach.BlobKey(f.DiskName), true, nil + } + return url, true, nil +} + +// OrganisationLogoFields answers the logo keys an application adds to its +// own organisation payloads, named like the user payload's avatar keys: +// has_logo (bool), logo (the attachment's disk name, or nil) and logo_url +// (the OrganisationLogoSize thumbnail URL, or nil). An organisation without +// a logo answers false, nil and nil. Only a failing database lookup is an +// error. +func OrganisationLogoFields(ctx context.Context, app *backpack.App, org *models.Organisation) (map[string]any, error) { + out := map[string]any{"has_logo": false, "logo": nil, "logo_url": nil} + f, err := organisationLogo(ctx, app, org) + if err != nil { + return nil, err + } + if f == nil { + return out, nil + } + out["has_logo"] = true + out["logo"] = f.DiskName + url, ok, err := OrganisationLogoURL(ctx, app, org, OrganisationLogoSize) + if err != nil { + return nil, err + } + if ok && url != "" { + out["logo_url"] = url + } + return out, nil +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 2a5c0ad..fa66531 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -106,6 +106,8 @@ organisation: slug_invalid: The slug may only contain letters, numbers, dashes and underscores. description: Description avatar: Avatar + logo: Logo + logo_comment: Shown wherever the site displays the organisation. members: Members new: New Organisation list_empty: No organisations yet. Create the first one with New Organisation. diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 37ab203..50978b8 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -106,6 +106,8 @@ organisation: slug_invalid: Identyfikator może zawierać tylko litery, cyfry, myślniki i podkreślenia. description: Opis avatar: Awatar + logo: Logo + logo_comment: Wyświetlane wszędzie, gdzie strona pokazuje organizację. members: Członkowie new: Nowa organizacja list_empty: Brak organizacji. Utwórz pierwszą przyciskiem Nowa organizacja. diff --git a/models/admin_models_test.go b/models/admin_models_test.go index 2e11cda..3b4175f 100644 --- a/models/admin_models_test.go +++ b/models/admin_models_test.go @@ -53,9 +53,9 @@ func TestAdminModelContracts(t *testing.T) { if (User{}).MorphName() != `Golem15\User\Models\User` || (Organisation{}).MorphName() != `Golem15\User\Models\Organisation` { t.Fatalf("morph names = %q / %q", (User{}).MorphName(), (Organisation{}).MorphName()) } - for name, relations := range map[string]int{"user": len((User{}).AttachRelations()), "organisation": len((Organisation{}).AttachRelations())} { - if relations != 1 { - t.Fatalf("%s attach relations = %d", name, relations) + for name, want := range map[string][2]int{"user": {len((User{}).AttachRelations()), 1}, "organisation": {len((Organisation{}).AttachRelations()), 2}} { + if want[0] != want[1] { + t.Fatalf("%s attach relations = %d, want %d", name, want[0], want[1]) } } if rel := (User{}).AttachRelations()[0]; AvatarField != "avatar" || rel.Name != AvatarField || !rel.Public { @@ -64,6 +64,9 @@ func TestAdminModelContracts(t *testing.T) { if rel := (Organisation{}).AttachRelations()[0]; rel.Name != OrganisationAvatarField || !rel.Public { t.Fatalf("organisation avatar relation = %+v", rel) } + if rel := (Organisation{}).AttachRelations()[1]; OrganisationLogoField != "logo" || rel.Name != OrganisationLogoField || !rel.Public { + t.Fatalf("organisation logo relation = %+v", rel) + } if got := (User{}).FilterScopes(); !reflect.DeepEqual(got, []string{FilterByGroup}) || FilterByGroup != "filterByGroup" { t.Fatalf("User.FilterScopes = %v", got) diff --git a/models/organisation.go b/models/organisation.go index b4d8691..5a34014 100644 --- a/models/organisation.go +++ b/models/organisation.go @@ -34,9 +34,15 @@ func (Organisation) MorphName() string { return `Golem15\User\Models\Organisatio // organisation's picture. const OrganisationAvatarField = "avatar" -// AttachRelations declares the public attachOne avatar (PHP $attachOne). +// OrganisationLogoField is the attachOne relation name of the +// organisation's logo, the image a site shows wherever it displays the +// organisation. +const OrganisationLogoField = "logo" + +// AttachRelations declares the public attachOne avatar (PHP $attachOne) and +// the public attachOne logo. func (Organisation) AttachRelations() []attach.Relation { - return []attach.Relation{{Name: OrganisationAvatarField, Public: true}} + return []attach.Relation{{Name: OrganisationAvatarField, Public: true}, {Name: OrganisationLogoField, Public: true}} } // Fillable is the PHP $fillable list. diff --git a/models/organisation/fields.yaml b/models/organisation/fields.yaml index b8679d5..7c9bc32 100644 --- a/models/organisation/fields.yaml +++ b/models/organisation/fields.yaml @@ -22,6 +22,13 @@ fields: mode: image imageWidth: 120 imageHeight: 120 + logo: + label: golem15.user::lang.organisation.logo + comment: golem15.user::lang.organisation.logo_comment + type: fileupload + mode: image + imageWidth: 240 + imageHeight: 120 members: type: relation-manager relation: members