feat(cabana): add backend user avatars to My account and the top bar

- cabana.BackendUser is an attach.Owner (MorphName Backend\Models\User,
  cabana.BackendUserMorphType) with one public attachOne avatar
  (cabana.BackendUserAvatarField), as WinterCMS stores it
- POST /account/avatar stores one file_data image (jpg, jpeg, png, gif or
  webp, image guard, at most cabana.AvatarMaxBytes = 2 MiB, under a
  server-generated name) and replaces the previous avatar: old rows are
  deleted in the transaction, their blobs after the commit
- DELETE /account/avatar removes the avatar and is idempotent
- GET /auth/me and the account routes return avatar {url, thumb_url}
  (250x250 crop); cabana.BackendUserAvatar lets plugins read it
- swag annotations, regenerated admin.json and schema.d.ts; route
  inventories, CSRF walk (29) and OpenAPI conformance learn both routes
- SPA: avatar upload and remove on My account, avatar image in the user
  menu trigger and header; backend::lang.myaccount avatar strings
- rebuilt boardwalk dist; cabana README, users-and-permissions and
  admin-spa docs
This commit is contained in:
2026-10-09 17:45:43 +02:00
parent 143eca5a1f
commit 64d21c8caf
25 changed files with 1012 additions and 49 deletions

View File

@@ -64,6 +64,20 @@
],
"type": "object"
},
"cabana.AdminAvatar": {
"properties": {
"thumb_url": {
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"cabana.AdminFileCaptionRequest": {
"properties": {
"description": {
@@ -158,6 +172,14 @@
},
"cabana.AdminProfile": {
"properties": {
"avatar": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.AdminAvatar"
}
],
"description": "Avatar is present when the admin has a public avatar."
},
"email": {
"type": "string"
},
@@ -2132,6 +2154,136 @@
]
}
},
"/account/avatar": {
"delete": {
"description": "Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Remove my avatar",
"tags": [
"admin"
]
},
"post": {
"description": "Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\\Models\\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file_data": {
"description": "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)",
"format": "binary",
"type": "string"
}
},
"required": [
"file_data"
],
"type": "object"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_AdminProfile"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Upload my avatar",
"tags": [
"admin"
]
}
},
"/auth/login": {
"post": {
"parameters": [
@@ -2240,7 +2392,7 @@
},
"/auth/me": {
"get": {
"description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag and the role.",
"description": "The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, \"\" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.",
"responses": {
"200": {
"content": {

View File

@@ -84,6 +84,132 @@ export interface paths {
patch?: never;
trace?: never;
};
"/account/avatar": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload my avatar
* @description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.
*/
post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/**
* Format: binary
* @description Image (jpg, jpeg, png, gif or webp, at most 2 MiB)
*/
file_data: string;
};
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
/**
* Remove my avatar
* @description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.
*/
delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_AdminProfile"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/login": {
parameters: {
query?: never;
@@ -209,7 +335,7 @@ export interface paths {
};
/**
* Current admin
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag and the role.
* @description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
*/
get: {
parameters: {
@@ -4401,6 +4527,10 @@ export interface components {
};
message: string;
};
"cabana.AdminAvatar": {
thumb_url?: string;
url: string;
};
"cabana.AdminFileCaptionRequest": {
description?: string;
title?: string;
@@ -4428,6 +4558,8 @@ export interface components {
html: string;
};
"cabana.AdminProfile": {
/** @description Avatar is present when the admin has a public avatar. */
avatar?: components["schemas"]["cabana.AdminAvatar"];
email: string;
first_name: string;
id: number;

View File

@@ -11,6 +11,8 @@ export type AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
export type AdminLogoutData = Schemas['cabana.AdminLogoutData']
export type AdminProfile = Schemas['cabana.AdminProfile']
/** An admin's avatar: the public image URL and its 250x250 thumbnail URL. */
export type AdminAvatar = Schemas['cabana.AdminAvatar']
/** Body of PUT /account: the only keys My account may change. */
export type AdminAccountRequest = Schemas['cabana.AdminAccountRequest']
export type NavigationEntry = Schemas['cabana.NavigationEntry']

View File

@@ -11,13 +11,14 @@ import {
} from 'reka-ui'
import { ChevronDown, LogOut, UserRound } from '@lucide/vue'
import { t } from '../../app/i18n'
import { currentUser, logout } from '../../state/useAuth'
import { currentUser, logout, profileInitials } from '../../state/useAuth'
// User menu (design: Top header): a 44px button with the initials avatar,
// the name above the role from /auth/me (both hidden at tablet width) and a
// chevron; the 280px menu (Reka DropdownMenu, role menu) shows the name and
// e-mail, a My account item that opens /account and a Wyloguj item. Logging out always ends on the login screen,
// even when the logout call fails (T-10-23).
// User menu (design: Top header): a 44px button with the avatar image (the
// initials when the admin has none), the name above the role from /auth/me
// (both hidden at tablet width) and a chevron; the 280px menu (Reka
// DropdownMenu, role menu) shows the name and e-mail, a My account item that
// opens /account and a Wyloguj item. Logging out always ends on the login
// screen, even when the logout call fails (T-10-23).
const router = useRouter()
const displayName = computed(() => {
@@ -30,20 +31,10 @@ const displayName = computed(() => {
})
/** First and last name initials, else the login's first letters. */
const initials = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
if (parts.length > 0) {
return parts
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
return user.login.slice(0, 2).toUpperCase()
})
const initials = computed(() => profileInitials(currentUser.value))
/** The avatar thumbnail, else the image; '' shows the initials. */
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
const roleName = computed(() => currentUser.value?.role?.name ?? '')
@@ -66,7 +57,15 @@ async function onLogout(): Promise<void> {
:aria-label="`${t('backend::lang.nav.user_menu')}: ${displayName}`"
class="flex h-input items-center gap-2.5 rounded-inner px-2 text-left transition-colors duration-150 ease-out hover:bg-hover"
>
<img
v-if="avatarSrc"
:src="avatarSrc"
alt=""
data-user-avatar
class="size-[34px] shrink-0 rounded-full object-cover"
/>
<span
v-else
class="flex size-[34px] shrink-0 items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
data-user-initials
@@ -86,7 +85,15 @@ async function onLogout(): Promise<void> {
class="z-50 flex w-[280px] flex-col rounded-[14px] border border-border bg-surface p-2 text-text shadow-menu"
>
<div class="flex items-center gap-3 px-2 py-2">
<img
v-if="avatarSrc"
:src="avatarSrc"
alt=""
data-user-avatar
class="size-10 shrink-0 rounded-full object-cover"
/>
<span
v-else
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-[14px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span

View File

@@ -54,6 +54,21 @@ export async function me(): Promise<AdminProfile | null> {
return user.value
}
/** First and last name initials, else the login's first two letters. */
export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_name' | 'login'> | null): string {
if (!user) {
return ''
}
const parts = [user.first_name, user.last_name].map((part) => part.trim()).filter(Boolean)
if (parts.length > 0) {
return parts
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
return user.login.slice(0, 2).toUpperCase()
}
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
export function setCurrentUser(profile: AdminProfile): void {
user.value = profile

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { CircleAlert } from '@lucide/vue'
import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminAccountRequest } from '../api/types'
import { t } from '../app/i18n'
@@ -8,15 +8,19 @@ import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import { controlClass } from '../components/form/control'
import { fieldErrors, focusField } from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import { currentUser, setCurrentUser } from '../state/useAuth'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { currentUser, profileInitials, setCurrentUser } from '../state/useAuth'
import { showToast } from '../state/useToasts'
// My account (WinterCMS Users::myaccount): the signed-in admin edits their
// own first name, last name, email and subtitle, and changes the password
// with the current one. Login is shown read-only. PUT /account takes exactly
// the allowed keys; the password keys are sent only when a new password is
// typed. Every success passes the returned profile to setCurrentUser, so the
// top bar updates without a reload.
// typed. The avatar is uploaded (POST /account/avatar, one file_data part)
// and removed (DELETE /account/avatar, after a confirmation) on its own.
// Every success passes the returned profile to setCurrentUser, so the top
// bar updates without a reload.
const ID_PREFIX = 'account'
type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle'
@@ -78,6 +82,90 @@ const busy = ref(false)
const login = computed(() => currentUser.value?.login ?? '')
// Avatar: the thumbnail, else the image, else the initials.
const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
const avatarInput = ref<HTMLInputElement | null>(null)
const avatarBusy = ref(false)
const avatarErrors = ref<string[]>([])
const avatarSrc = computed(() => currentUser.value?.avatar?.thumb_url || currentUser.value?.avatar?.url || '')
const initials = computed(() => profileInitials(currentUser.value))
const confirm = useConfirm()
function chooseAvatar(): void {
avatarInput.value?.click()
}
async function onAvatarPicked(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || avatarBusy.value) {
return
}
avatarBusy.value = true
avatarErrors.value = []
try {
// The typed client keeps the 401 refresh-and-replay; FormData lets the
// browser set the multipart Content-Type and boundary.
const result = await api.POST('/account/avatar', {
body: { file_data: '' },
bodySerializer: () => {
const form = new FormData()
form.append('file_data', file, file.name)
return form
},
})
if (result.data) {
setCurrentUser(result.data.data)
showToast(t('backend::lang.myaccount.avatar_saved'))
return
}
if (result.response.status === 422) {
const details = fieldErrors(result.error?.error.details)
avatarErrors.value = details.avatar ?? Object.values(details).flat()
return
}
if (result.response.status === 413) {
avatarErrors.value = [t('backend::lang.myaccount.avatar_hint')]
return
}
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
avatarBusy.value = false
}
}
async function onAvatarRemove(): Promise<void> {
if (avatarBusy.value) {
return
}
const confirmed = await confirm.ask({
message: t('backend::lang.myaccount.avatar_remove_confirm'),
confirmLabel: t('backend::lang.myaccount.avatar_remove'),
danger: true,
})
if (!confirmed) {
return
}
avatarBusy.value = true
avatarErrors.value = []
try {
const result = await api.DELETE('/account/avatar')
if (result.data) {
setCurrentUser(result.data.data)
showToast(t('backend::lang.myaccount.avatar_removed'))
return
}
showToast(t('backend::lang.form.error_generic'), 'danger')
} catch {
showToast(t('backend::lang.form.error_generic'), 'danger')
} finally {
avatarBusy.value = false
}
}
function inputId(name: string): string {
return `${ID_PREFIX}-${name}`
}
@@ -164,6 +252,67 @@ async function onSave(): Promise<void> {
<FormErrorBanner :errors="errors" :field-names="FIELD_ORDER" />
<div class="flex flex-col gap-3 rounded-card border border-border bg-surface p-7 shadow-card" data-account-avatar>
<h2 class="text-[18px] font-bold">{{ t('backend::lang.myaccount.avatar') }}</h2>
<div class="flex flex-wrap items-center gap-5">
<img
v-if="avatarSrc"
:src="avatarSrc"
alt=""
data-account-avatar-image
class="size-20 shrink-0 rounded-full object-cover"
/>
<span
v-else
aria-hidden="true"
data-account-avatar-initials
class="flex size-20 shrink-0 items-center justify-center rounded-full bg-accent text-[26px] font-bold text-on-accent"
>{{ initials }}</span
>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2.5">
<Button
variant="outline"
size="sm"
:icon="ImageUp"
data-action="avatar-upload"
:disabled="avatarBusy"
@click="chooseAvatar"
>
{{ t('backend::lang.myaccount.avatar_upload') }}
</Button>
<Button
v-if="currentUser?.avatar"
variant="danger"
size="sm"
:icon="Trash2"
data-action="avatar-remove"
:disabled="avatarBusy"
@click="onAvatarRemove"
>
{{ t('backend::lang.myaccount.avatar_remove') }}
</Button>
</div>
<p class="text-[13px] text-muted">{{ t('backend::lang.myaccount.avatar_hint') }}</p>
</div>
<input
ref="avatarInput"
type="file"
name="file_data"
:accept="AVATAR_ACCEPT"
class="hidden"
data-account-avatar-input
@change="onAvatarPicked"
/>
</div>
<div v-if="avatarErrors.length > 0" role="alert" data-avatar-error class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in avatarErrors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />
<span>{{ text }}</span>
</p>
</div>
</div>
<form
class="flex flex-col gap-7 rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@@ -259,6 +408,16 @@ async function onSave(): Promise<void> {
</fieldset>
</form>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
:busy="confirm.busy.value"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>
<footer
class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>

View File

@@ -33,7 +33,7 @@ A list row that carries a state (deleted, negative or disabled) shows a text bad
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
The user menu in the top bar has a My account item that opens `<prefix>/account`. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, and changes the password when a new one is typed together with the current one. Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See [My account](users-and-permissions.md#my-account).
## Types from OpenAPI

View File

@@ -82,6 +82,9 @@ Every signed-in administrator can edit their own profile on the My account scree
- **Email:** required, a valid address and 6 to 255 characters long. It must not equal, ignoring case, the email or login of another administrator, deleted administrators included, because a sign-in identifier that matches two administrators locks both out. Your own email in a different case is accepted. Changing the email does not ask for the password, as in WinterCMS: the framework sends no password-reset emails, so the email is not a recovery channel.
- **Password:** leave it blank to keep the current one. A new password needs `current_password`, at least 4 characters, at most 72 bytes (bcrypt's limit) and an equal `password_confirmation`. A wrong current password answers 422 on `current_password` and is written to the authentication log. A changed password is hashed at `admin.password.bcrypt_cost` and ends every other session: tokens issued before the change are refused. The browser that made the change gets a fresh session cookie and stays signed in; an API client that used a Bearer token has to sign in again.
- **Subtitle:** a short line such as a role or position. It is stored where WinterCMS keeps it, under the `subtitle` key of the jsonable `backend_users.metadata` column, so rows copied from WinterCMS keep their subtitles and other metadata keys survive a save. Code reads it with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as no subtitle. `GET <prefix>/api/v1/auth/me` returns it as `subtitle`.
- **Avatar:** `POST <prefix>/api/v1/account/avatar` takes one multipart part named `file_data`. Only jpg, jpeg, png, gif and webp images that pass the image check are accepted, up to `cabana.AvatarMaxBytes` (2 MiB) and within `http.body_limits.upload_bytes`; anything else answers 422 on `avatar`, and a body over the cap answers 413. The file is stored under a name the server generates, never the uploaded file name. An upload replaces the previous avatar: the old row is deleted with the new one's save and its files right after. `DELETE <prefix>/api/v1/account/avatar` removes the avatar and answers 200 also when there is none. The avatar is a public attachOne in `system_files` with attachment type `Backend\Models\User` (`cabana.BackendUserMorphType`) and field `avatar` (`cabana.BackendUserAvatarField`), exactly as WinterCMS stores it, so avatar rows copied from WinterCMS work as they are. `GET .../auth/me` returns its `url` and a 250x250 crop `thumb_url`, and the top bar shows it in place of the initials.
Plugins that show an administrator, such as an author box, read the subtitle with `cabana.BackendUser.Subtitle` and the picture with `cabana.BackendUserAvatar`, which returns the avatar file (or nil) for an administrator id.
## Managing administrators

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<link rel="icon" href="./favicon.ico" sizes="48x48" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-1UJLNPmg.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-go9AISaH.css">
<script type="module" crossorigin src="./assets/index-D3quaPLf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
</head>
<body>
<div id="app"></div>

View File

@@ -33,7 +33,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Singleton settings screens declared with `pact.HasSettings`, read and saved by `cabana.SettingsService`. A settings entry whose `Controller` field is set links to that admin controller instead: start-up fails when it also declares a model, form or model factory, or names an unknown controller; `GET /settings` lists it, with its `controller`, only to administrators who pass its permissions and may open the controller; and the singleton settings endpoints answer 404 for its code.
- Backend navigation (`pact.HasNavigation`) and permissions (`pact.HasPermissions`), filtered per user by `cabana.Registry.Metadata`. An administrator's own `backend_users.permissions` are merged over the role's as in Winter (a `-1` denies a code the role grants). `cabana.Allows` implements the permission check with Winter's `hasAnyAccess` semantics: superusers pass, a principal needs any one of the listed codes, and wildcards match on both sides (a grant ending in `.*` covers every code with that prefix, and a required code ending in `.*` is met by any grant under it).
- Admin authentication against WinterCMS's `backend_users` and `backend_user_roles` tables (`cabana.BackendUser`, `cabana.BackendUserRole`, `cabana.BackendUsers`): a JWT guard registered in [bouncer](../bouncer/README.md) as `backend` (a guard another plugin already registered under that name fails `cabana.Activate`), login throttling, token refresh and revocation, and two transports. API clients use a Bearer token; the SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in the HttpOnly, SameSite=Strict cookie named by `cabana.AdminCookieName`. Cookie-authenticated requests that change state must carry that header, which blocks cross-site request forgery.
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie.
- My account: the signed-in administrator opens `/account` from the user menu and edits their own first name, last name, email and subtitle, and changes their password with the current one (PUT `/account`, body `cabana.AdminAccountRequest`). The row is always the token subject, never a request value, and any key outside the request type (login, role, superuser flag, permissions, activation) is a 422 on that key with nothing written. The subtitle is stored where WinterCMS keeps it, under `subtitle` in the jsonable `backend_users.metadata` column (`cabana.BackendUser.Metadata`), and is read with `cabana.BackendUser.Subtitle`, which treats NULL, `[]` and malformed metadata as an empty subtitle; saving it keeps every other metadata key. A password change ends every other session through `tokens_valid_after` and gives a cookie session a fresh cookie. The same screen uploads and removes the administrator's avatar (POST and DELETE `/account/avatar`): a public attachOne in `system_files` under WinterCMS's morph name (`cabana.BackendUserMorphType`, field `cabana.BackendUserAvatarField`; `cabana.BackendUser` implements `attach.Owner` and `attach.HasRelations`), so avatar rows copied from WinterCMS are picked up as they are. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most `cabana.AvatarMaxBytes` (2 MiB), are stored, under a server-generated name. Plugins read the picture with `cabana.BackendUserAvatar`.
- A consistent JSON envelope for every response: `cabana.WriteData`, `cabana.WriteError` and `cabana.WriteErrorDetails`, typed for documentation as `cabana.Envelope`, `cabana.ListEnvelope`, `cabana.RecordEnvelope` and `cabana.ErrorEnvelope`. A body that cannot be encoded is logged and answered with the generic 500 envelope, never a success status with a truncated body.
- OpenAPI documentation: `cabana.AdminList`, `cabana.AdminCreate` and the other `Admin*` functions have empty bodies and exist only to carry the swag annotations of each admin route.
- Operator commands for creating administrators and resetting their passwords (see CLI commands).
@@ -46,8 +46,9 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|-----------------|---------|
| POST `/auth/login`, POST `/auth/refresh` | Sign in (throttled) and refresh a token. Public. |
| GET `/lang` | The `backend::lang` string bundle for the request locale. Public, so the login screen can load it. |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle`). |
| POST `/auth/logout`, GET `/auth/me` | Revoke the current token, also when its access lifetime has expired but its refresh window is open, and clear the session cookie; return the signed-in administrator (`cabana.AdminProfile`, with the `subtitle` and, when there is one, the `avatar` URLs as a `cabana.AdminAvatar`). |
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
@@ -217,7 +218,12 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.BackendUser.Subtitle` | The administrator's subtitle, `backend_users.metadata.subtitle`; empty for NULL, `[]`, malformed metadata or a non-string value. |
| `cabana.AdminProfile` | The signed-in administrator returned by GET `/auth/me` and the My account routes: id, login, email, names, `subtitle`, superuser flag and role. |
| `cabana.AdminAccountRequest` | Body of PUT `/account`: `email`, `first_name`, `last_name`, `subtitle` and the optional `current_password`, `password` and `password_confirmation`. These are the only accepted keys. |
| `cabana.AdminAccountUpdate` | Swag annotation of the My account route. |
| `cabana.AdminAccountUpdate` / `cabana.AdminAccountAvatarUpload` / `cabana.AdminAccountAvatarRemove` | Swag annotations of the My account routes. |
| `cabana.AdminAvatar` | The `avatar` of a `cabana.AdminProfile`: the public `url` and the 250x250 crop `thumb_url` (left out when the thumbnail cannot be made). |
| `cabana.AvatarMaxBytes` | Largest avatar image the upload route accepts, 2 MiB; `http.body_limits.upload_bytes` still caps the request. |
| `cabana.BackendUserAvatar` | Returns a backend user's avatar `attach.File` (the newest avatar row), or nil when there is none; `File.URL` and `File.Thumb` give its public URLs. |
| `cabana.BackendUserMorphType` / `cabana.BackendUserAvatarField` | The avatar's `system_files.attachment_type` (WinterCMS's `Backend\Models\User`) and `field` (`avatar`). |
| `cabana.BackendUser.MorphName` / `cabana.BackendUser.AttachRelations` | `attach.Owner` and `attach.HasRelations` of the backend user: one public attachOne `avatar`. |
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"slices"
@@ -13,8 +14,10 @@ import (
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/jackc/pgx/v5/pgconn"
"gocloud.dev/blob"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -23,6 +26,24 @@ import (
// the key WinterCMS's Journal author component reads.
const metadataSubtitle = "subtitle"
// AvatarMaxBytes is the largest avatar image POST /account/avatar accepts:
// 2 MiB. http.body_limits.upload_bytes still caps the whole request.
const AvatarMaxBytes int64 = 2 << 20
// avatarThumbEdge is the edge of the avatar's crop thumbnail, WinterCMS's
// myaccount avatar size.
const avatarThumbEdge = 250
// avatarLimits are the attach.Store rules of an avatar: a raster image (jpg,
// jpeg, png, gif or webp, a sniffed image MIME type and the decode guard) of
// at most AvatarMaxBytes.
var avatarLimits = attach.Limits{
MaxBytes: AvatarMaxBytes,
Extensions: attach.DefaultImageExtensions,
MIMETypes: attach.AllowedImageMIMEs,
Image: true,
}
// passwordMaxBytes is bcrypt's input limit: HashPassword refuses longer input.
const passwordMaxBytes = 72
@@ -120,8 +141,10 @@ func setMetadataString(raw *string, key, value string) (*string, error) {
}
// profileFor is the AdminProfile of user, the payload of GET /auth/me and of
// the My account routes.
func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) AdminProfile {
// the My account routes. The avatar is looked up through db; a lookup or
// thumbnail failure is logged and leaves the avatar (or its thumb_url) out,
// so the profile never fails on it.
func (s *service) profileFor(ctx context.Context, db *gorm.DB, user BackendUser) AdminProfile {
profile := AdminProfile{
ID: user.ID,
Login: user.Login,
@@ -134,9 +157,60 @@ func (s *service) profileFor(_ context.Context, _ *gorm.DB, user BackendUser) Ad
if user.Role.ID != 0 {
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
}
if db != nil {
profile.Avatar = s.avatarOf(ctx, db, user.ID)
}
return profile
}
// avatarOf is the public URL and the 250x250 crop thumbnail URL of an
// admin's avatar, or nil when there is none. A protected row (is_public
// false, possible only in copied data) has no public URL and is left out.
func (s *service) avatarOf(ctx context.Context, db *gorm.DB, userID uint) *AdminAvatar {
f, err := BackendUserAvatar(ctx, db, userID)
if err != nil {
slog.Default().WarnContext(ctx, "cabana: avatar lookup failed", "admin_id", userID, "error", err)
return nil
}
if f == nil || !f.Public() {
return nil
}
out := &AdminAvatar{URL: f.URL()}
if bucket := s.bucket(); bucket != nil && slices.Contains(attach.AllowedImageMIMEs, f.ContentType) {
thumb, err := f.Thumb(ctx, bucket, avatarThumbEdge, avatarThumbEdge, "crop")
if err != nil {
slog.Default().WarnContext(ctx, "cabana: avatar thumbnail failed", "admin_id", userID, "file_id", f.ID, "error", err)
} else {
out.ThumbURL = thumb
}
}
return out
}
// BackendUserAvatar returns a backend user's avatar: the newest system_files
// row with attachment_type cabana.BackendUserMorphType, field
// cabana.BackendUserAvatarField and the user's id, or nil, nil when there is
// none. Plugins call it to show an author's picture (File.URL, File.Thumb).
func BackendUserAvatar(ctx context.Context, db *gorm.DB, userID uint) (*attach.File, error) {
if db == nil {
return nil, errors.New("cabana: avatar lookup without a database")
}
if ctx == nil {
ctx = context.Background()
}
var files []attach.File
err := db.Session(&gorm.Session{NewDB: true, Context: ctx}).
Where("attachment_type = ? AND field = ? AND attachment_id = ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID)).
Order("id DESC").Limit(1).Find(&files).Error
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, nil
}
return &files[0], nil
}
// accountPrincipal is the signed-in backend principal, or nil after a 401
// was written.
func accountPrincipal(w http.ResponseWriter, r *http.Request) *bouncer.Principal {
@@ -430,3 +504,208 @@ func (s *service) writeAccountError(w http.ResponseWriter, r *http.Request, err
}
writeCRUDError(w, err)
}
// avatarCap is the request body cap of an avatar upload: AvatarMaxBytes plus
// the multipart framing, lowered to http.body_limits.upload_bytes.
func (s *service) avatarCap() int64 {
limit := AvatarMaxBytes + multipartOverhead
if s != nil && s.uploadBytes > 0 && s.uploadBytes < limit {
limit = s.uploadBytes
}
return limit
}
// detachAvatars deletes every avatar row of an admin inside tx and returns
// the blob keys of the originals and their thumbnails. The rows are locked
// first; the caller deletes the blobs after the commit.
func detachAvatars(ctx context.Context, tx *gorm.DB, userID uint, keep uint) ([]string, error) {
var rows []attach.File
err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("attachment_type = ? AND field = ? AND attachment_id = ? AND id <> ?", BackendUserMorphType, BackendUserAvatarField, uitoa(userID), keep).
Order("id").Find(&rows).Error
if err != nil || len(rows) == 0 {
return nil, err
}
ids := make([]uint, 0, len(rows))
var keys []string
for _, f := range rows {
ids = append(ids, f.ID)
keys = append(keys, attach.BlobKeys(f)...)
}
if err := tx.Session(&gorm.Session{NewDB: true, Context: ctx}).Where("id IN ?", ids).Delete(&attach.File{}).Error; err != nil {
return nil, err
}
return keys, nil
}
// deleteAvatarBlobs removes detached avatar blobs once tx has committed; a
// failure is logged and never fails the request.
func deleteAvatarBlobs(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, userID uint, keys []string) {
if len(keys) == 0 {
return
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
if bucket == nil {
slog.Default().WarnContext(ctx, "cabana: no storage bucket; blobs of a removed avatar were kept", "admin_id", userID)
return
}
if err := attach.DeleteKeys(context.WithoutCancel(ctx), bucket, keys); err != nil {
slog.Default().WarnContext(ctx, "cabana: deleting a removed avatar's blobs failed", "admin_id", userID, "error", err)
}
})
}
// avatarUpload serves POST /account/avatar: one multipart file_data part,
// stored through attach.Store as a public image of at most AvatarMaxBytes
// under a server-generated disk name, attached to the signed-in admin as the
// avatar attachOne. The previous avatar row is deleted in the same
// transaction and its blobs after the commit.
func (s *service) avatarUpload(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
bucket := s.bucket()
if bucket == nil {
slog.Default().ErrorContext(r.Context(), "cabana: avatar upload without a storage bucket")
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.avatarCap())}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
if err != nil {
writeCRUDError(w, invalidBody())
return
}
part, err := mr.NextPart()
if err != nil {
s.writeAvatarError(w, r, body, err)
return
}
if part.FormName() != "file_data" || strings.TrimSpace(part.FileName()) == "" {
writeCRUDError(w, invalidBody())
return
}
var stored *attach.File
var saved BackendUser
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
// The row lock serialises concurrent avatar changes of one admin.
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
// Store keeps only a sanitized base name as file_name; the disk name
// and the blob key are generated, never taken from the client.
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: true}, avatarLimits)
if err != nil {
return err
}
stored = f
// Exactly one part: anything after file_data is refused.
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
if err == nil {
return invalidBody()
}
return err
}
q := tx.Session(&gorm.Session{NewDB: true, Context: ctx})
if err := q.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{
"attachment_id": uitoa(user.ID),
"attachment_type": BackendUserMorphType,
"field": BackendUserAvatarField,
}).Error; err != nil {
return err
}
keys, err := detachAvatars(ctx, tx, user.ID, f.ID)
if err != nil {
return err
}
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
saved = user
return nil
})
if err != nil {
// attach.Store wrote the blob before the row; a rolled-back
// transaction leaves it to this caller.
if stored != nil {
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
}
s.writeAvatarError(w, r, body, err)
return
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// avatarRemove serves DELETE /account/avatar: it deletes the signed-in
// admin's avatar rows now and their blobs after the commit. Removing an
// avatar that is not there also answers 200.
func (s *service) avatarRemove(w http.ResponseWriter, r *http.Request) {
principal := accountPrincipal(w, r)
if principal == nil {
return
}
db, err := s.db()
if err != nil {
slog.Default().ErrorContext(r.Context(), "cabana: avatar removal without a database", "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
bucket := s.bucket()
var saved BackendUser
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
user, err := lockAccount(ctx, tx, principal.ID)
if err != nil {
return err
}
keys, err := detachAvatars(ctx, tx, user.ID, 0)
if err != nil {
return err
}
deleteAvatarBlobs(ctx, tx, bucket, user.ID, keys)
saved = user
return nil
})
if err != nil {
s.writeAccountError(w, r, err)
return
}
WriteData(w, http.StatusOK, s.profileFor(r.Context(), db, saved), map[string]any{})
}
// writeAvatarError maps an avatar upload failure as writeFileError does,
// with the field name avatar: a body past the cap is 413, an attach.Store
// refusal a 422 on avatar and a malformed multipart body a 422 on body.
func (s *service) writeAvatarError(w http.ResponseWriter, r *http.Request, body *bodyReader, err error) {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) || (body != nil && errors.As(body.err, &tooBig)) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
ctx, tr := r.Context(), s.translator()
var detail string
switch {
case errors.Is(err, attach.ErrTooLarge):
detail = fieldMessage(ctx, tr, "max.file", BackendUserAvatarField, map[string]string{"max": strconv.FormatInt(AvatarMaxBytes/1024, 10)})
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType):
detail = fieldMessage(ctx, tr, "mimes", BackendUserAvatarField, map[string]string{"values": strings.Join(attach.DefaultImageExtensions, ", ")})
case errors.Is(err, attach.ErrNotImage):
detail = fieldMessage(ctx, tr, "image", BackendUserAvatarField, nil)
}
if detail != "" {
writeCRUDError(w, &ValidationError{Details: fieldDetail(BackendUserAvatarField, detail)})
return
}
if body != nil && body.err != nil {
writeCRUDError(w, invalidBody())
return
}
s.writeAccountError(w, r, err)
}

View File

@@ -1,12 +1,26 @@
package cabana_test
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// accountStamp is a per-run suffix for logins and emails, so the shared
@@ -91,3 +105,90 @@ func TestAccountProfileSmoke(t *testing.T) {
t.Fatalf("is_superuser=%v err=%v", superuser, err)
}
}
// avatarHandler assembles the admin router like adminHandler, with a memblob
// attachment bucket published and an 8 MiB upload cap. withBucket false
// leaves the bucket out.
func avatarHandler(t *testing.T, gdb *gorm.DB, withBucket bool) (http.Handler, *blob.Bucket) {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-account\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1 << 20, "http.body_limits.upload_bytes": 8 << 20} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
var bucket *blob.Bucket
if withBucket {
bucket = memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
if err := attach.Publish(app, bucket); err != nil {
t.Fatal(err)
}
}
h, err := surf.Assemble(app, []party.Plugin{demoPlugin{fsys: demoFS()}})
if err != nil {
t.Fatal(err)
}
return h, bucket
}
// uploadAvatar posts one multipart part (name field, file name name) to
// POST /account/avatar with a Bearer token.
func uploadAvatar(t *testing.T, h http.Handler, token, field, name string, data []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile(field, name)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(data); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, adminAPI("/account/avatar"), &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAccountAvatarSmoke(t *testing.T) {
gdb := adminGorm(t)
h, _ := avatarHandler(t, gdb, true)
login := "acct-avatar-" + accountStamp()
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
token := signIn(t, h, login)
up := uploadAvatar(t, h, token, "file_data", "me.png", conformPNG(t))
if up.Code != http.StatusOK {
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
}
me := getAuth(t, h, adminAPI("/auth/me"), token)
got := profileOf(t, me.Body.Bytes())
if got.Avatar == nil || got.Avatar.URL == "" || got.Avatar.ThumbURL == "" {
t.Fatalf("me avatar = %+v body=%s", got.Avatar, me.Body.String())
}
del := postAuth(t, h, http.MethodDelete, adminAPI("/account/avatar"), token, nil)
if del.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", del.Code, del.Body.String())
}
me = getAuth(t, h, adminAPI("/auth/me"), token)
if got := profileOf(t, me.Body.Bytes()); got.Avatar != nil {
t.Fatalf("avatar after delete = %+v", got.Avatar)
}
}

View File

@@ -108,6 +108,15 @@ type AdminProfile struct {
Subtitle string `json:"subtitle"`
IsSuperuser bool `json:"is_superuser"`
Role *AdminRoleSummary `json:"role,omitempty"`
// Avatar is present when the admin has a public avatar.
Avatar *AdminAvatar `json:"avatar,omitempty"`
}
// AdminAvatar is an admin's avatar: the public URL of the image and of its
// 250x250 crop thumbnail (left out when the thumbnail cannot be made).
type AdminAvatar struct {
URL string `json:"url"`
ThumbURL string `json:"thumb_url,omitempty"`
}
// AdminAccountRequest is the body of PUT /account. It documents the only keys
@@ -181,7 +190,7 @@ func AdminLogout() {}
// AdminMe documents GET /auth/me.
//
// @Summary Current admin
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag and the role.
// @Description The signed-in admin's profile: login, email, names, the subtitle (backend_users.metadata.subtitle, "" when not set), the superuser flag, the role and, when the admin has one, the avatar with its public url and the 250x250 crop thumb_url.
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -209,6 +218,36 @@ func AdminMe() {}
// @Router /account [put]
func AdminAccountUpdate() {}
// AdminAccountAvatarUpload documents POST /account/avatar.
//
// @Summary Upload my avatar
// @Description Stores one multipart file_data part as the signed-in admin's avatar, a public attachOne (system_files attachment_type Backend\Models\User, field avatar) under a server-generated name, and replaces the previous avatar. Only jpg, jpeg, png, gif and webp images that pass the image check, of at most 2 MiB, are accepted; anything else is a 422 on avatar. A body over the upload cap answers 413 payload_too_large.
// @Tags admin
// @Accept multipart/form-data
// @Produce json
// @Security BackendBearer
// @Param file_data formData file true "Image (jpg, jpeg, png, gif or webp, at most 2 MiB)"
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /account/avatar [post]
func AdminAccountAvatarUpload() {}
// AdminAccountAvatarRemove documents DELETE /account/avatar.
//
// @Summary Remove my avatar
// @Description Removes the signed-in admin's avatar and its files. Removing an avatar that is not there also answers 200.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /account/avatar [delete]
func AdminAccountAvatarRemove() {}
// AdminNavigation documents GET /navigation.
//
// @Summary Admin navigation

View File

@@ -9,6 +9,7 @@ import (
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
@@ -59,6 +60,25 @@ type BackendUser struct {
func (BackendUser) TableName() string { return "backend_users" }
const (
// BackendUserMorphType is the system_files attachment_type of a backend
// user's files: WinterCMS's class string, so avatar rows copied from
// WinterCMS match as they are.
BackendUserMorphType = "Backend\\Models\\User"
// BackendUserAvatarField is the attachOne relation (system_files.field)
// of a backend user's avatar.
BackendUserAvatarField = "avatar"
)
// MorphName implements attach.Owner with WinterCMS's class string.
func (BackendUser) MorphName() string { return BackendUserMorphType }
// AttachRelations implements attach.HasRelations: one public attachOne
// avatar, as WinterCMS's Backend\Models\User declares it.
func (BackendUser) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: BackendUserAvatarField, Public: true}}
}
// Subtitle returns metadata.subtitle, the key WinterCMS's Journal author
// component reads. NULL, blank, null, [], invalid JSON and a non-string value
// all read as an empty subtitle.

View File

@@ -238,6 +238,8 @@ func (s *service) mount(r pact.Router) {
g.Get("/auth/me", s.me)
// My account: the signed-in admin edits their own profile.
g.Put("/account", requireAjax(s.accountUpdate))
g.Post("/account/avatar", requireAjax(s.avatarUpload))
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
g.Get("/navigation", s.navigation)
// Form previews render through RenderMarkdown; nothing is stored.
g.Post("/markdown/preview", requireAjax(s.markdownPreview))

View File

@@ -366,6 +366,34 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"POST /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file_data", "avatar.png")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(conformPNG(t)); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
rec := e.sendWith(t, http.MethodPost, "/account/avatar", buf.Bytes(), mw.FormDataContentType(), nil)
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar == nil || body.Data.Avatar.URL == "" {
t.Fatalf("avatar upload body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"DELETE /account/avatar", 200, "cabana.Envelope-cabana_AdminProfile", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.sendWith(t, http.MethodDelete, "/account/avatar", nil, "", nil)
var body cabana.Envelope[cabana.AdminProfile]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Avatar != nil {
t.Fatalf("avatar remove body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.AdminProfile]](), nil},
{"POST /auth/logout", 200, "cabana.Envelope-cabana_AdminLogoutData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},

View File

@@ -88,9 +88,11 @@ func TestPhase10Coverage(t *testing.T) {
}
sort.Strings(unsafe)
want := []string{
"DELETE /account/avatar",
"DELETE /{vendor}/{plugin}/{controller}/{id}",
"DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}",
"DELETE /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
"POST /account/avatar",
"POST /auth/login",
"POST /auth/logout",
"POST /auth/refresh",
@@ -118,7 +120,7 @@ func TestPhase10Coverage(t *testing.T) {
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 27 besides login):\n%s", strings.Join(unsafe, "\n"))
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 29 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.

View File

@@ -66,14 +66,14 @@ func TestPhase10CSRF(t *testing.T) {
}
})
}
// refresh, logout, account update, markdown preview, settings put, create, bulk-delete, bulk action,
// refresh, logout, account update, avatar upload and remove, markdown preview, settings put, create, bulk-delete, bulk action,
// widget action, toolbar action, update, delete, record action, link,
// unlink, file
// upload, file reorder, file caption, file remove, relation child
// create, update and delete, pivot update, child file upload, reorder,
// caption and remove
if unsafe != 27 {
t.Fatalf("walked %d state-changing routes, want 27: %v", unsafe, router.order)
if unsafe != 29 {
t.Fatalf("walked %d state-changing routes, want 29: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

View File

@@ -40,6 +40,8 @@ var phase09Routes = []adminRoute{
{key: "POST /auth/logout", public: true},
{key: "GET /auth/me"},
{key: "PUT /account"},
{key: "POST /account/avatar"},
{key: "DELETE /account/avatar"},
{key: "GET /navigation"},
{key: "GET /settings"},
{key: "GET /settings/{code}/schema"},

View File

@@ -248,3 +248,10 @@ myaccount:
field_not_editable: This field cannot be changed on My account.
subtitle_unavailable: The subtitle cannot be saved because the stored profile metadata is not a JSON object.
saved: Account saved
avatar: Avatar
avatar_upload: Upload picture
avatar_remove: Remove picture
avatar_remove_confirm: Remove your avatar?
avatar_hint: JPG, PNG, GIF or WebP, up to 2 MB.
avatar_saved: Avatar saved
avatar_removed: Avatar removed

View File

@@ -270,3 +270,10 @@ myaccount:
field_not_editable: Tego pola nie można zmienić w ustawieniach konta.
subtitle_unavailable: Nie można zapisać podtytułu, ponieważ zapisane metadane profilu nie są obiektem JSON.
saved: Zapisano konto
avatar: Awatar
avatar_upload: Wgraj zdjęcie
avatar_remove: Usuń zdjęcie
avatar_remove_confirm: Usunąć awatar?
avatar_hint: JPG, PNG, GIF lub WebP, maksymalnie 2 MB.
avatar_saved: Zapisano awatar
avatar_removed: Usunięto awatar