feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

3
.gitignore vendored
View File

@@ -6,6 +6,9 @@
*.out *.out
coverage.* coverage.*
# Admin SPA
/admin/node_modules/
# Env and local config # Env and local config
.env .env
.env.* .env.*

1
admin/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

14
admin/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2238
admin/openapi/admin.json Normal file

File diff suppressed because it is too large Load Diff

4403
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
admin/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "summercms-admin",
"private": true,
"type": "module",
"engines": {
"node": ">=22.6"
},
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"gen:api": "openapi-typescript openapi/admin.json -o src/api/schema.d.ts"
},
"dependencies": {
"@fontsource/dm-mono": "5.3.0",
"@fontsource/dm-sans": "5.3.0",
"@lucide/vue": "1.17.0",
"openapi-fetch": "0.17.0",
"reka-ui": "2.9.10",
"vue": "3.5.35",
"vue-router": "5.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.0",
"@vitejs/plugin-vue": "6.0.8",
"@vue/test-utils": "2.4.11",
"happy-dom": "20.11.6",
"openapi-typescript": "7.13.0",
"tailwindcss": "4.3.0",
"typescript": "5.9.3",
"vite": "7.3.5",
"vitest": "3.2.7",
"vue-tsc": "3.3.11"
}
}

15
admin/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import AppShell from './components/shell/AppShell.vue'
const route = useRoute()
const inShell = computed(() => route.meta.shell === true)
</script>
<template>
<AppShell v-if="inShell">
<RouterView />
</AppShell>
<RouterView v-else />
</template>

44
admin/src/api/client.ts Normal file
View File

@@ -0,0 +1,44 @@
// The only HTTP client of the SPA: openapi-fetch typed by the generated
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
// admin API requires on state-changing cookie requests, D-19) and same-origin
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
import createClient, { type Middleware } from 'openapi-fetch'
import type { paths } from './schema'
import { runtime } from '../app/runtime'
export const REQUESTED_WITH = 'XMLHttpRequest'
type UnauthorizedHandler = () => void
let unauthorizedHandler: UnauthorizedHandler | null = null
/** Registers what happens when an API call other than login returns 401. */
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
unauthorizedHandler = handler
}
function isLoginRequest(request: Request): boolean {
return new URL(request.url, 'http://local').pathname.endsWith('/auth/login')
}
export const transport: Middleware = {
onRequest({ request }) {
request.headers.set('X-Requested-With', REQUESTED_WITH)
return request
},
onResponse({ request, response }) {
if (response.status === 401 && !isLoginRequest(request)) {
unauthorizedHandler?.()
}
return response
},
}
export const api = createClient<paths>({
baseUrl: runtime.api,
credentials: 'same-origin',
// Resolve fetch per call so tests can replace globalThis.fetch.
fetch: (request: Request) => globalThis.fetch(request),
})
api.use(transport)

1532
admin/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

16
admin/src/api/types.ts Normal file
View File

@@ -0,0 +1,16 @@
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
// written by hand: every type here points at components['schemas'].
import type { components } from './schema'
type Schemas = components['schemas']
export type AdminLoginData = Schemas['cabana.AdminLoginData']
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
export type AdminProfile = Schemas['cabana.AdminProfile']
export type NavigationEntry = Schemas['cabana.NavigationEntry']
export type ListSchema = Schemas['cabana.ListSchema']
export type ListColumn = Schemas['cabana.ListColumn']
export type ListMeta = Schemas['cabana.ListMeta']
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
/** One record: a string-keyed map read through its list or form schema. */
export type AdminRecord = Schemas['cabana.ListEnvelope-array_cabana_AdminRecord']['data'][number]

View File

@@ -0,0 +1,42 @@
// Controller IDs map one to one onto SPA paths (D-10):
// vendor.plugin.controller <-> /vendor/plugin/controller.
export interface ControllerParams {
vendor: string
plugin: string
controller: string
}
const SEGMENT = /^[A-Za-z0-9_-]+$/
export function parseControllerId(id: string): ControllerParams | null {
const parts = id.split('.')
if (parts.length !== 3 || !parts.every((part) => SEGMENT.test(part))) {
return null
}
const [vendor, plugin, controller] = parts as [string, string, string]
return { vendor, plugin, controller }
}
export function controllerPath(id: string): string | null {
const params = parseControllerId(id)
return params ? `/${params.vendor}/${params.plugin}/${params.controller}` : null
}
export function controllerIdFromParams(params: ControllerParams): string {
return `${params.vendor}.${params.plugin}.${params.controller}`
}
export function controllerIdFromPath(path: string): string | null {
const parts = path.replace(/^\/+|\/+$/g, '').split('/')
if (parts.length < 3) {
return null
}
const id = parts.slice(0, 3).join('.')
return parseControllerId(id) ? id : null
}
/** vendor.plugin prefix of a controller ID, used to find the owning plugin. */
export function pluginKey(id: string): string | null {
const params = parseControllerId(id)
return params ? `${params.vendor}.${params.plugin}` : null
}

42
admin/src/app/i18n.ts Normal file
View File

@@ -0,0 +1,42 @@
// UI strings (D-20). The resolved backend::lang bundle is loaded from the
// server in a later plan; until a key is loaded, t() returns the key itself,
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's
// :name syntax with :Name and :NAME casing variants (D-24).
import { ref } from 'vue'
type Forms = Record<string, string>
const bundle = ref<Record<string, Forms>>({})
export function setBundle(next: Record<string, Forms>): void {
bundle.value = next
}
export function interpolate(text: string, params: Record<string, string | number> = {}): string {
const replacements: Array<[string, string]> = []
for (const [rawName, raw] of Object.entries(params)) {
const name = rawName.replace(/^:/, '')
if (name === '') {
continue
}
const value = String(raw)
replacements.push([`:${name.charAt(0).toUpperCase()}${name.slice(1)}`, value.charAt(0).toUpperCase() + value.slice(1)])
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
replacements.push([`:${name}`, value])
}
replacements.sort((a, b) => b[0].length - a[0].length)
let out = text
for (const [placeholder, value] of replacements) {
out = out.split(placeholder).join(value)
}
return out
}
export function t(key: string, params: Record<string, string | number> = {}): string {
const forms = bundle.value[key]
const text = forms?.other
if (text === undefined) {
return key
}
return interpolate(text, params)
}

111
admin/src/app/icons.ts Normal file
View File

@@ -0,0 +1,111 @@
// Navigation icons (D-11). The registry stores lucide names; ported Winter
// plugins may still send icon-* names, which map onto lucide equivalents.
// Named imports only: a namespace import would bundle every lucide icon.
import type { Component } from 'vue'
import {
Archive,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpRight,
CassetteTape,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
CircleAlert,
Disc,
Disc3,
Image,
Library,
List,
LogOut,
MicVocal,
Minus,
Palette,
PanelLeftClose,
PanelLeftOpen,
Plus,
Puzzle,
Search,
SearchX,
Settings,
ShieldCheck,
Square,
Sun,
Tags,
Trash2,
User,
UserMinus,
UserPlus,
Users,
UsersRound,
X,
} from '@lucide/vue'
export const icons: Readonly<Record<string, Component>> = {
archive: Archive,
'arrow-down': ArrowDown,
'arrow-left': ArrowLeft,
'arrow-right': ArrowRight,
'arrow-up': ArrowUp,
'arrow-up-right': ArrowUpRight,
'cassette-tape': CassetteTape,
check: Check,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft,
'chevron-right': ChevronRight,
circle: Circle,
'circle-alert': CircleAlert,
disc: Disc,
'disc-3': Disc3,
image: Image,
library: Library,
list: List,
'log-out': LogOut,
'mic-vocal': MicVocal,
minus: Minus,
palette: Palette,
'panel-left-close': PanelLeftClose,
'panel-left-open': PanelLeftOpen,
plus: Plus,
puzzle: Puzzle,
search: Search,
'search-x': SearchX,
settings: Settings,
'shield-check': ShieldCheck,
sun: Sun,
tags: Tags,
'trash-2': Trash2,
user: User,
'user-minus': UserMinus,
'user-plus': UserPlus,
users: Users,
'users-round': UsersRound,
x: X,
}
/** Winter backend icon classes mapped to lucide names. */
export const winterIcons: Readonly<Record<string, string>> = {
'icon-archive': 'archive',
'icon-circle': 'circle',
'icon-list-ul': 'list',
'icon-tags': 'tags',
'icon-user': 'user',
'icon-search': 'search',
'icon-cog': 'settings',
'icon-users': 'users',
}
/** Neutral icon for names neither lucide map knows. */
export const fallbackIcon: Component = Square
export function iconFor(name: string | null | undefined): Component {
if (!name) {
return fallbackIcon
}
const key = name.trim()
return icons[key] ?? icons[winterIcons[key] ?? ''] ?? fallbackIcon
}

63
admin/src/app/router.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createRouter, createWebHistory, type RouterHistory } from 'vue-router'
import { runtime } from './runtime'
import { currentUser } from '../state/useAuth'
import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
interface RouteMeta {
/** Reachable without a session. */
public?: boolean
/** Rendered inside the navigation shell. */
shell?: boolean
}
}
/**
* Accepts a post-login redirect only when it is an in-app path: it must start
* with exactly one slash, which rejects absolute and protocol-relative URLs.
*/
export function safeRedirect(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0) {
return null
}
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return null
}
return value
}
export function createAdminRouter(history: RouterHistory = createWebHistory(runtime.base)) {
const router = createRouter({
history,
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
name: 'home',
component: NotFoundView,
props: { home: true },
meta: { shell: true },
beforeEnter: () => homePath() ?? true,
},
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
],
})
router.beforeEach((to) => {
const signedIn = currentUser.value !== null
if (to.meta.public) {
if (signedIn && to.name === 'login') {
return safeRedirect(to.query.redirect) ?? '/'
}
return true
}
if (!signedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
return router
}

23
admin/src/app/runtime.ts Normal file
View File

@@ -0,0 +1,23 @@
// Runtime configuration read once from the served index.html. The Go server
// (boardwalk) writes the configured backend.uri into the summer-admin-base
// meta; the SPA derives its router base and API base from it (D-02, D-03).
export const DEFAULT_BASE = '/backend'
const TOKEN = '__SUMMER_ADMIN_BASE__'
export function readBase(doc: Document = document): string {
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content.trim() ?? ''
if (content === '' || content === TOKEN || !content.startsWith('/')) {
return DEFAULT_BASE
}
const trimmed = content.replace(/\/+$/, '')
return trimmed === '' ? DEFAULT_BASE : trimmed
}
const base = readBase()
export const runtime = {
/** Admin mount path, for example /backend. */
base,
/** Admin API root, the mount path plus /api/v1. */
api: `${base}/api/v1`,
} as const

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
// Read-only, schema-driven table: columns come from the list schema and each
// record is read through its column keys (D-16). Selection, sorting and
// row links arrive with the list screens.
defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
emptyText?: string
}>()
function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key])
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
const parts = value.map(formatValue).filter((part) => part !== '—')
return parts.length === 0 ? '—' : parts.join(', ')
}
if (typeof value === 'boolean') {
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
}
if (typeof value === 'object') {
return '—'
}
return String(value)
}
function rowKey(row: AdminRecord, index: number): string {
const id = row.id
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
}
</script>
<template>
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-left">
<thead>
<tr class="h-row-head border-y border-border bg-subtle">
<th
v-for="column in columns"
:key="column.key"
scope="col"
class="px-3.5 text-[12px] font-semibold text-muted first:pl-5 last:pr-5"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<tr v-for="n in 8" :key="`skeleton-${n}`" class="h-row border-b border-border">
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<span class="block h-3 w-3/5 rounded-[6px] bg-skel" />
</td>
</tr>
</template>
<tr v-else-if="rows.length === 0">
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
{{ emptyText ?? t('backend::lang.list.no_records') }}
</td>
</tr>
<template v-else>
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
class="px-3.5 first:pl-5 last:pr-5"
>
{{ cellText(row, column) }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
import { activeEntry } from '../../state/useNavigation'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const section = computed(() => {
const id = controllerIdFromPath(route.path)
return active.value?.sideMenu.find((item) => item.controller === id) ?? null
})
const displayName = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join(''),
)
</script>
<template>
<div class="flex min-h-screen bg-bg text-text">
<PluginRail />
<SectionPanel v-if="active" :entry="active" />
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8">
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2">
<template v-if="active">
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
<template v-if="section">
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
<span class="font-semibold" aria-current="page">{{ section.label }}</span>
</template>
</template>
</nav>
<div v-if="currentUser" class="flex h-input items-center gap-2.5 rounded-inner px-2">
<span
class="flex size-[34px] items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span
>
<span class="flex flex-col leading-tight">
<span class="font-semibold">{{ displayName }}</span>
<span v-if="currentUser.role" class="text-[12px] text-muted">{{ currentUser.role.name }}</span>
</span>
</div>
</header>
<main class="min-w-0 flex-1 px-8 py-7">
<slot />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const items = computed(() =>
railEntries.value.map((entry) => ({
entry,
to: firstControllerPath(entry) ?? '/',
icon: iconFor(entry.icon),
current: active.value?.code === entry.code,
})),
)
</script>
<template>
<nav
:aria-label="t('backend::lang.nav.plugins')"
class="flex w-rail shrink-0 flex-col items-center gap-1.5 border-r border-side-border bg-side pt-3.5 pb-3"
>
<span class="mb-3.5 flex size-10 items-center justify-center rounded-inner bg-accent-soft" title="SummerCMS">
<Sun :size="22" class="text-accent" aria-hidden="true" />
</span>
<RouterLink
v-for="item in items"
:key="item.entry.code"
:to="item.to"
:aria-current="item.current ? 'page' : undefined"
:class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline"
>
<component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.entry.label }}</span>
</RouterLink>
</nav>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { t } from '../../app/i18n'
const props = defineProps<{ entry: NavigationEntry }>()
const route = useRoute()
const currentController = computed(() => controllerIdFromPath(route.path))
const items = computed(() =>
props.entry.sideMenu.flatMap((item) => {
const to = controllerPath(item.controller)
if (!to) {
return []
}
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}),
)
</script>
<template>
<aside
:aria-label="t('backend::lang.nav.sections')"
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
>
<div class="mb-3.5 flex h-10 items-center px-2">
<span class="text-[16px] font-bold">{{ entry.label }}</span>
</div>
<RouterLink
v-for="link in items"
:key="link.item.code"
:to="link.to"
:aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline"
>
<component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span>
</RouterLink>
</aside>
</template>

30
admin/src/main.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation'
import './styles/main.css'
async function boot(): Promise<void> {
// A 401 here only means "not signed in"; the router guard sends the
// visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null)
if (user) {
await loadNavigation().catch(() => [])
}
const router = createAdminRouter()
onUnauthorized(() => {
clearUser()
const current = router.currentRoute.value
if (current.name !== 'login') {
void router.push({ name: 'login', query: { redirect: current.fullPath } })
}
})
const app = createApp(App)
app.use(router)
await router.isReady()
app.mount('#app')
}
void boot()

View File

@@ -0,0 +1,44 @@
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
// keeps only the profile and the access lifetime, never a token.
import { readonly, ref } from 'vue'
import { api } from '../api/client'
import type { AdminProfile } from '../api/types'
const user = ref<AdminProfile | null>(null)
const expiresIn = ref<number | null>(null)
export const currentUser = readonly(user)
/** Logs in over cookie transport. Returns false on invalid credentials. */
export async function login(identifier: string, password: string): Promise<boolean> {
const { data, response } = await api.POST('/auth/login', {
body: { login: identifier, password },
})
if (!response.ok || !data) {
return false
}
expiresIn.value = typeof data.data.expires_in === 'number' ? data.data.expires_in : null
return true
}
/** Loads the signed-in admin; null when the session is missing or expired. */
export async function me(): Promise<AdminProfile | null> {
const { data, response } = await api.GET('/auth/me')
user.value = response.ok && data ? data.data : null
return user.value
}
export function clearUser(): void {
user.value = null
expiresIn.value = null
}
export function useAuth() {
return {
user: currentUser,
expiresIn: readonly(expiresIn),
login,
me,
clearUser,
}
}

View File

@@ -0,0 +1,65 @@
// Server-filtered navigation (D-10). The server removes items the admin may
// not open; the rail also omits a plugin whose side menu ends up empty.
import { computed, readonly, ref } from 'vue'
import { api } from '../api/client'
import type { NavigationEntry } from '../api/types'
import { controllerPath, pluginKey } from '../app/controllerRoutes'
const entries = ref<NavigationEntry[]>([])
export const navigation = readonly(entries)
export async function loadNavigation(): Promise<NavigationEntry[]> {
const { data, response } = await api.GET('/navigation')
entries.value = response.ok && data ? data.data : []
return entries.value
}
export function setNavigation(next: NavigationEntry[]): void {
entries.value = next
}
/** Rail entries: plugins with at least one permitted side-menu item, by order. */
export const railEntries = computed<NavigationEntry[]>(() =>
entries.value
.filter((entry) => entry.sideMenu.length > 0)
.map((entry, index) => ({ entry, index }))
.sort((a, b) => a.entry.order - b.entry.order || a.index - b.index)
.map(({ entry }) => entry),
)
/** Path of the plugin's first permitted side-menu controller. */
export function firstControllerPath(entry: NavigationEntry): string | null {
for (const item of entry.sideMenu) {
const path = controllerPath(item.controller)
if (path) {
return path
}
}
return controllerPath(entry.controller)
}
/** The rail entry owning a route's vendor and plugin segments. */
export function activeEntry(vendor: string, plugin: string): NavigationEntry | null {
const key = `${vendor}.${plugin}`
return (
railEntries.value.find(
(entry) => pluginKey(entry.controller) === key || entry.sideMenu.some((item) => pluginKey(item.controller) === key),
) ?? null
)
}
/** Where "/" lands: the first plugin's first controller. */
export function homePath(): string | null {
for (const entry of railEntries.value) {
const path = firstControllerPath(entry)
if (path) {
return path
}
}
return null
}
export function useNavigation() {
return { navigation, railEntries, loadNavigation, firstControllerPath, activeEntry, homePath }
}

160
admin/src/styles/main.css Normal file
View File

@@ -0,0 +1,160 @@
@import "tailwindcss";
/* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with
unicode-range, so Polish diacritics and basic Latin both resolve. */
@import "@fontsource/dm-sans/400.css";
@import "@fontsource/dm-sans/500.css";
@import "@fontsource/dm-sans/600.css";
@import "@fontsource/dm-sans/700.css";
@import "@fontsource/dm-mono/400.css";
@import "@fontsource/dm-mono/500.css";
@custom-variant dark (&:where(.dark, .dark *));
/* Design tokens from design/README.md (Direction C v2). Utilities read CSS
variables so light and dark mode swap values without new classes. */
@theme {
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, monospace;
--color-bg: var(--c-bg);
--color-surface: var(--c-surface);
--color-subtle: var(--c-subtle);
--color-border: var(--c-border);
--color-border-strong: var(--c-border-strong);
--color-text: var(--c-text);
--color-muted: var(--c-muted);
--color-placeholder: var(--c-placeholder);
--color-primary: var(--c-primary);
--color-on-primary: var(--c-on-primary);
--color-danger: var(--c-danger);
--color-danger-soft: var(--c-danger-soft);
--color-ok-bg: var(--c-ok-bg);
--color-ok-text: var(--c-ok-text);
--color-ring: var(--c-ring);
--color-hover: var(--c-hover);
--color-sel: var(--c-sel);
--color-skel: var(--c-skel);
--color-overlay: var(--c-overlay);
--color-side: var(--c-side);
--color-side-border: var(--c-side-border);
--color-accent: #fcd34d;
--color-on-accent: #1b2540;
--color-accent-soft: rgba(252, 211, 77, 0.14);
--color-side-text: #c3cbda;
--color-side-label: #9aa6bd;
--color-side-hover: rgba(255, 255, 255, 0.08);
--radius-control: 10px;
--radius-card: 16px;
--radius-modal: 20px;
--radius-inner: 12px;
--radius-tab: 9px;
--radius-checkbox: 5px;
--radius-pager: 8px;
--radius-pill: 999px;
--spacing-button: 42px;
--spacing-input: 44px;
--spacing-header: 64px;
--spacing-nav: 40px;
--spacing-row: 54px;
--spacing-row-head: 44px;
--spacing-pager: 34px;
--spacing-rail: 80px;
--spacing-panel: 224px;
--shadow-card: var(--c-shadow-card);
--shadow-login: 0 24px 60px rgba(0, 0, 0, 0.35);
--shadow-pop: 0 16px 40px rgba(20, 27, 45, 0.18);
--shadow-menu: 0 16px 40px rgba(20, 27, 45, 0.16);
--shadow-toast: 0 16px 40px rgba(0, 0, 0, 0.25);
--shadow-tab: 0 1px 3px rgba(20, 27, 45, 0.12);
}
:root {
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-subtle: #f3f5f8;
--c-border: #e6e9ef;
--c-border-strong: #d2d8e2;
--c-text: #141b2d;
--c-muted: #566175;
--c-placeholder: #6b7588;
--c-primary: #22304d;
--c-on-primary: #ffffff;
--c-danger: #c62828;
--c-danger-soft: #fdf0f0;
--c-ok-bg: #e3f4e8;
--c-ok-text: #1c6b35;
--c-ring: rgba(252, 196, 40, 0.55);
--c-hover: #f1f3f7;
--c-sel: #fdf3cf;
--c-skel: #eceff4;
--c-overlay: rgba(20, 27, 45, 0.5);
--c-side: #1d2740;
--c-side-border: transparent;
--c-shadow-card: 0 1px 2px rgba(20, 27, 45, 0.04), 0 4px 16px rgba(20, 27, 45, 0.05);
}
.dark {
--c-bg: #111726;
--c-surface: #182033;
--c-subtle: #1f283d;
--c-border: #29334b;
--c-border-strong: #3a4661;
--c-text: #eef1f6;
--c-muted: #a9b3c6;
--c-placeholder: #8a95ab;
--c-primary: #fcd34d;
--c-on-primary: #1b2540;
--c-danger: #f58a8a;
--c-danger-soft: #3a1d24;
--c-ok-bg: #173826;
--c-ok-text: #8fdfa8;
--c-ring: rgba(252, 211, 77, 0.45);
--c-hover: #212b42;
--c-sel: #3a3622;
--c-skel: #263049;
--c-overlay: rgba(5, 8, 16, 0.7);
--c-side: #0d1320;
--c-side-border: #222b40;
--c-shadow-card: none;
}
@layer base {
html {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
background: var(--c-bg);
}
body {
margin: 0;
min-height: 100vh;
}
/* Every interactive element shows the 3px ring; never remove it. */
a:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid var(--c-ring);
outline-offset: 2px;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px var(--c-ring);
}
input::placeholder,
textarea::placeholder {
color: var(--c-placeholder);
}
}

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
import { controllerIdFromParams } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import DataTable from '../components/list/DataTable.vue'
import { activeEntry } from '../state/useNavigation'
const route = useRoute()
const path = computed(() => ({
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}))
const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
const title = computed(() => {
if (schema.value?.title) {
return schema.value.title
}
const id = controllerIdFromParams(path.value)
const entry = activeEntry(path.value.vendor, path.value.plugin)
return entry?.sideMenu.find((item) => item.controller === id)?.label ?? id
})
const range = computed(() => {
const m = meta.value
if (!m || m.total === 0) {
return t('backend::lang.list.no_results')
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
let generation = 0
async function load(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
const params = { path: path.value }
const [schemaResult, listResult] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params }),
api.GET('/{vendor}/{plugin}/{controller}', { params }),
])
if (current !== generation) {
return
}
schema.value = schemaResult.data?.data ?? null
rows.value = listResult.data?.data ?? []
meta.value = listResult.data?.meta ?? null
failed.value = !schemaResult.data || !listResult.data
loading.value = false
}
watch(path, load, { immediate: true, deep: true })
</script>
<template>
<section class="flex flex-col gap-5">
<header>
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
</header>
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
<p v-if="failed && !loading" role="alert" class="px-5 py-4 text-danger">
{{ t('backend::lang.list.load_failed') }}
</p>
<DataTable
v-else
:columns="schema?.columns ?? []"
:rows="rows"
:loading="loading"
:empty-text="schema?.noRecordsMessage"
/>
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
{{ loading ? t('backend::lang.list.loading') : range }}
</footer>
</div>
</section>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CircleAlert, Sun } from '@lucide/vue'
import { t } from '../app/i18n'
import { safeRedirect } from '../app/router'
import { login, me } from '../state/useAuth'
import { loadNavigation } from '../state/useNavigation'
const route = useRoute()
const router = useRouter()
const identifier = ref('')
const password = ref('')
const failed = ref(false)
const busy = ref(false)
async function submit(): Promise<void> {
if (busy.value) {
return
}
busy.value = true
failed.value = false
try {
const ok = await login(identifier.value.trim(), password.value)
if (!ok || !(await me())) {
failed.value = true
return
}
password.value = ''
await loadNavigation()
await router.replace(safeRedirect(route.query.redirect) ?? '/')
} finally {
busy.value = false
}
}
</script>
<template>
<main
class="flex min-h-screen items-center justify-center px-4"
style="background: radial-gradient(ellipse at 50% 35%, #2b3a5c 0%, #1d2740 60%)"
>
<div class="flex w-full max-w-[400px] flex-col gap-6">
<div class="flex items-center justify-center gap-3">
<span class="flex size-[34px] items-center justify-center rounded-full bg-accent-soft">
<Sun :size="20" class="text-accent" aria-hidden="true" />
</span>
<span class="text-[18px] font-bold text-white">Summer<span class="text-accent">CMS</span></span>
</div>
<form
class="flex flex-col gap-[18px] rounded-card bg-white p-8 text-[#141b2d] shadow-login"
novalidate
@submit.prevent="submit"
>
<h1 class="text-[22px] font-bold tracking-[-0.02em]">{{ t('backend::lang.auth.title') }}</h1>
<label class="flex flex-col gap-1.5">
<span class="font-semibold">{{ t('backend::lang.auth.login') }}</span>
<input
v-model="identifier"
name="login"
type="text"
autocomplete="username"
required
class="h-input rounded-control border border-[#d2d8e2] bg-white px-3.5"
/>
</label>
<label class="flex flex-col gap-1.5">
<span class="font-semibold">{{ t('backend::lang.auth.password') }}</span>
<input
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
:aria-invalid="failed ? 'true' : undefined"
:class="failed ? 'border-[#c62828]' : 'border-[#d2d8e2]'"
class="h-input rounded-control border bg-white px-3.5"
/>
</label>
<div
v-if="failed"
role="alert"
class="flex items-center gap-2.5 rounded-control bg-[#fdf0f0] px-3.5 py-3 text-[#c62828]"
>
<CircleAlert :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.auth.invalid') }}</span>
</div>
<button
type="submit"
:disabled="busy"
class="h-input rounded-control bg-[#22304d] font-semibold text-white disabled:opacity-60"
>
{{ t('backend::lang.auth.submit') }}
</button>
</form>
</div>
</main>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { t } from '../app/i18n'
defineProps<{ home?: boolean }>()
</script>
<template>
<section class="flex flex-col items-center gap-2 py-20 text-center">
<h1 class="text-[17px] font-bold">
{{ home ? t('backend::lang.nav.empty') : t('backend::lang.page.not_found') }}
</h1>
</section>
</template>

55
admin/tests/fixtures/navigation.json vendored Normal file
View File

@@ -0,0 +1,55 @@
{
"data": [
{
"code": "demo",
"label": "Demo",
"icon": "disc-3",
"order": 100,
"controller": "acme.demo.widgets",
"sideMenu": [
{
"code": "widgets",
"label": "Widgets",
"icon": "tags",
"order": 0,
"controller": "acme.demo.widgets",
"sideMenu": []
},
{
"code": "gadgets",
"label": "Gadgets",
"icon": "icon-archive",
"order": 0,
"controller": "acme.demo.gadgets",
"sideMenu": []
}
]
},
{
"code": "tools",
"label": "Tools",
"icon": "unknown-icon-name",
"order": 300,
"controller": "acme.tools.hammers",
"sideMenu": [
{
"code": "hammers",
"label": "Hammers",
"icon": "puzzle",
"order": 0,
"controller": "acme.tools.hammers",
"sideMenu": []
}
]
},
{
"code": "hidden",
"label": "Hidden",
"icon": "users",
"order": 200,
"controller": "acme.hidden.items",
"sideMenu": []
}
],
"meta": { "locale": "en" }
}

View File

@@ -0,0 +1,22 @@
{
"data": {
"title": "Widgets",
"recordsPerPage": 20,
"perPageOptions": [],
"showSearch": true,
"showSetup": false,
"showCheckboxes": true,
"showSorting": true,
"searchTerm": "",
"toolbarButtons": ["create"],
"columns": [
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" }
],
"filters": [],
"rowActions": [],
"bulkActions": []
},
"meta": { "locale": "en" }
}

View File

@@ -0,0 +1,7 @@
{
"data": [
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"] },
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [] }
],
"meta": { "page": 1, "per_page": 20, "total": 2, "last_page": 1 }
}

6
admin/tests/setup.ts Normal file
View File

@@ -0,0 +1,6 @@
// The served index.html carries the admin base in a meta element; tests use a
// non-default base to prove the SPA reads it at runtime (D-02, D-03).
const meta = document.createElement('meta')
meta.setAttribute('name', 'summer-admin-base')
meta.setAttribute('content', '/admin-test')
document.head.appendChild(meta)

View File

@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory } from 'vue-router'
import App from '../../src/App.vue'
import LoginView from '../../src/views/LoginView.vue'
import { createAdminRouter, safeRedirect } from '../../src/app/router'
import { runtime } from '../../src/app/runtime'
import { onUnauthorized } from '../../src/api/client'
import { clearUser, me, useAuth } from '../../src/state/useAuth'
import { loadNavigation, setNavigation } from '../../src/state/useNavigation'
import navigation from '../fixtures/navigation.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
const API = '/admin-test/api/v1'
const profile = {
data: {
id: 7,
login: 'dev',
email: 'dev@example.test',
first_name: 'Dana',
last_name: 'Dev',
is_superuser: false,
role: { id: 2, code: 'developer', name: 'Developer' },
},
meta: {},
}
type Routes = Record<string, { status?: number; body: unknown }>
function mockApi(routes: Routes): Request[] {
const calls: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
const request = input as Request
calls.push(request)
const key = `${request.method} ${new URL(request.url).pathname}`
const route = routes[key]
const status = route ? (route.status ?? 200) : 404
const body = route ? route.body : { error: { code: 'not_found', message: 'Not found', details: {} } }
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
})
return calls
}
function pathOf(request: Request): string {
return new URL(request.url).pathname
}
beforeEach(() => {
clearUser()
setNavigation([])
onUnauthorized(null)
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe('runtime', () => {
it('reads the admin base and API base from the served meta', () => {
expect(runtime.base).toBe('/admin-test')
expect(runtime.api).toBe(API)
})
})
describe('login', () => {
it('posts the CSRF header with same-origin credentials and stores no token', async () => {
const calls = mockApi({
[`POST ${API}/auth/login`]: {
body: { data: { token_type: 'cookie', expires_in: 3600, access_token: 'must-not-be-kept' }, meta: {} },
},
})
const auth = useAuth()
expect(await auth.login('dev', 'secret')).toBe(true)
expect(calls).toHaveLength(1)
const request = calls[0]!
expect(request.method).toBe('POST')
expect(pathOf(request)).toBe(`${API}/auth/login`)
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(request.credentials).toBe('same-origin')
expect(await request.json()).toEqual({ login: 'dev', password: 'secret' })
expect(auth.expiresIn.value).toBe(3600)
expect(localStorage.length).toBe(0)
expect(sessionStorage.length).toBe(0)
expect(document.cookie).not.toContain('must-not-be-kept')
expect(JSON.stringify({ user: auth.user.value, expiresIn: auth.expiresIn.value })).not.toContain('must-not-be-kept')
})
it('shows the invalid-credentials alert and marks the password invalid', async () => {
mockApi({
[`POST ${API}/auth/login`]: {
status: 401,
body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } },
},
})
const router = createAdminRouter(createMemoryHistory())
await router.push('/login')
const wrapper = mount(LoginView, { global: { plugins: [router] } })
await wrapper.find('input[name="login"]').setValue('dev')
await wrapper.find('input[name="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
expect(wrapper.find('input[name="password"]').attributes('aria-invalid')).toBe('true')
expect(router.currentRoute.value.name).toBe('login')
})
})
describe('redirects', () => {
it('accepts only in-app paths that start with exactly one slash', () => {
expect(safeRedirect('/acme/demo/widgets?page=2')).toBe('/acme/demo/widgets?page=2')
expect(safeRedirect('https://evil.example/steal')).toBeNull()
expect(safeRedirect('//evil.example/steal')).toBeNull()
expect(safeRedirect('/\\evil.example')).toBeNull()
expect(safeRedirect('acme/demo')).toBeNull()
expect(safeRedirect(['/acme'])).toBeNull()
})
it('sends an unauthenticated visitor to login with the requested path', async () => {
const router = createAdminRouter(createMemoryHistory())
await router.push('/acme/demo/widgets?page=2')
expect(router.currentRoute.value.name).toBe('login')
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?page=2')
})
it('reports a 401 on any call other than login', async () => {
mockApi({
[`GET ${API}/auth/me`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } } },
[`POST ${API}/auth/login`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } } },
})
const handler = vi.fn()
onUnauthorized(handler)
await useAuth().login('dev', 'wrong')
expect(handler).not.toHaveBeenCalled()
await me()
expect(handler).toHaveBeenCalledTimes(1)
})
})
describe('navigation shell and list', () => {
async function mountApp() {
const calls = mockApi({
[`GET ${API}/auth/me`]: { body: profile },
[`GET ${API}/navigation`]: { body: navigation },
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
})
await me()
await loadNavigation()
const router = createAdminRouter(createMemoryHistory())
await router.push('/acme/demo/widgets')
const wrapper = mount(App, { global: { plugins: [router] } })
await flushPromises()
return { wrapper, calls, router }
}
it('renders navigation grouped by plugin and omits a plugin with an empty side menu', async () => {
const { wrapper } = await mountApp()
const rail = wrapper.find('nav[aria-label="backend::lang.nav.plugins"]')
expect(rail.exists()).toBe(true)
const railLinks = rail.findAll('a')
expect(railLinks.map((link) => link.text())).toEqual(['Demo', 'Tools'])
expect(railLinks[0]!.attributes('aria-current')).toBe('page')
expect(railLinks[1]!.attributes('aria-current')).toBeUndefined()
expect(railLinks[1]!.attributes('href')).toBe('/acme/tools/hammers')
// An unknown icon name renders the neutral fallback instead of failing.
expect(railLinks[1]!.find('svg').exists()).toBe(true)
const panel = wrapper.find('aside')
const panelLinks = panel.findAll('a')
expect(panel.text()).toContain('Demo')
expect(panelLinks.map((link) => link.text())).toEqual(['Widgets', 'Gadgets'])
expect(panelLinks[0]!.attributes('aria-current')).toBe('page')
expect(panelLinks[1]!.attributes('href')).toBe('/acme/demo/gadgets')
})
it('renders the list schema columns and rows through the typed client', async () => {
const { wrapper, calls } = await mountApp()
const requested = calls.map(pathOf)
expect(requested).toContain(`${API}/acme/demo/widgets/schema/list`)
expect(requested).toContain(`${API}/acme/demo/widgets`)
for (const request of calls) {
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
}
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
expect(rows).toEqual([
['Blue widget', 'W-01', 'small, round'],
['Green widget', 'W-02', '—'],
])
})
})

21
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "preserve",
"types": []
},
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "env.d.ts"]
}

34
admin/vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// The committed build is path-agnostic (D-02): assets use a relative base and
// index.html keeps the __SUMMER_ADMIN_BASE__ token, which the Go server
// (boardwalk) replaces with the configured backend.uri. The dev server
// replaces it with SUMMER_ADMIN_DEV_PREFIX and proxies the admin API to a
// running `summer serve` at SUMMER_ADMIN_DEV_TARGET.
const devPrefix = (process.env.SUMMER_ADMIN_DEV_PREFIX ?? '/backend').replace(/\/+$/, '')
const devTarget = process.env.SUMMER_ADMIN_DEV_TARGET ?? 'http://localhost:8080'
function devBase(): Plugin {
return {
name: 'summer-admin-dev-base',
apply: 'serve',
transformIndexHtml: (html) => html.replace('__SUMMER_ADMIN_BASE__', devPrefix),
}
}
export default defineConfig(({ command }) => ({
base: command === 'build' ? './' : '/',
plugins: [vue(), tailwindcss(), devBase()],
build: {
outDir: '../boardwalk/dist',
emptyOutDir: true,
sourcemap: false,
},
server: {
proxy: {
[`${devPrefix}/api`]: { target: devTarget, changeOrigin: false },
},
},
}))

12
admin/vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
include: ['tests/**/*.test.ts'],
setupFiles: ['tests/setup.ts'],
restoreMocks: true,
},
})

167
boardwalk/boardwalk.go Normal file
View File

@@ -0,0 +1,167 @@
// Package boardwalk serves the embedded admin SPA build (D-01, D-02).
//
// The committed dist/ is path-agnostic: Vite builds it with a relative base
// and index.html carries the __SUMMER_ADMIN_BASE__ token. Handler rewrites
// index.html once for the configured backend.uri, serves hashed assets with
// long-lived caching, falls back to index.html for client-side routes and
// hands every unmatched api/ path back to the caller so API misses stay JSON.
package boardwalk
import (
"bytes"
"embed"
"errors"
"fmt"
"html"
"io/fs"
"mime"
"net/http"
"path"
"strings"
"time"
)
//go:embed all:dist
var distFS embed.FS
// BaseToken is replaced in dist/index.html by the configured admin prefix.
const BaseToken = "__SUMMER_ADMIN_BASE__"
// contentSecurityPolicy keeps the admin out of frames and allows scripts
// only from its own origin; the build contains no inline script.
const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"
var contentTypes = map[string]string{
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".woff2": "font/woff2",
".woff": "font/woff",
".svg": "image/svg+xml",
".json": "application/json",
}
// Dist returns the embedded build tree rooted at dist/.
func Dist() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// Handler serves the embedded SPA under prefix (for example /backend).
// notFoundAPI answers any request whose path under the prefix is api or
// starts with api/; it must write the admin API's JSON 404 envelope.
func Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error) {
root, err := Dist()
if err != nil {
return nil, err
}
return newHandler(root, prefix, notFoundAPI)
}
func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handler, error) {
if notFoundAPI == nil {
return nil, errors.New("boardwalk: notFoundAPI handler is nil")
}
prefix = strings.TrimRight(prefix, "/")
if !strings.HasPrefix(prefix, "/") {
return nil, fmt.Errorf("boardwalk: prefix %q must start with /", prefix)
}
raw, err := fs.ReadFile(root, "index.html")
if err != nil {
return nil, fmt.Errorf("boardwalk: dist/index.html: %w", err)
}
index, err := RewriteIndex(raw, prefix)
if err != nil {
return nil, err
}
return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil
}
// RewriteIndex points relative asset URLs at prefix and injects the prefix
// into the summer-admin-base meta. It fails when the token is absent, which
// catches a stale or hand-edited dist at boot.
func RewriteIndex(raw []byte, prefix string) ([]byte, error) {
if !bytes.Contains(raw, []byte(BaseToken)) {
return nil, errors.New("boardwalk: dist/index.html has no " + BaseToken + " token; rebuild the admin SPA")
}
escaped := html.EscapeString(prefix)
out := bytes.ReplaceAll(raw, []byte(`="./`), []byte(`="`+escaped+`/`))
out = bytes.ReplaceAll(out, []byte(BaseToken), []byte(escaped))
return out, nil
}
type handler struct {
root fs.FS
prefix string
index []byte
notFoundAPI http.Handler
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w.Header())
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
rel = strings.TrimPrefix(rel, "/")
if rel == "api" || strings.HasPrefix(rel, "api/") {
h.notFoundAPI.ServeHTTP(w, r)
return
}
name := strings.TrimPrefix(path.Clean("/"+rel), "/")
if name == "" || name == "index.html" {
h.serveIndex(w, r)
return
}
if info, err := fs.Stat(h.root, name); err == nil {
if info.Mode().IsRegular() {
h.serveFile(w, r, name)
return
}
// A directory is never listed; it is treated as a client route.
h.serveIndex(w, r)
return
}
if path.Ext(name) != "" {
http.NotFound(w, r)
return
}
h.serveIndex(w, r)
}
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
}
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
body, err := fs.ReadFile(h.root, name)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType(name))
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
}
func contentType(name string) string {
ext := strings.ToLower(path.Ext(name))
if ct, ok := contentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
func setSecurityHeaders(h http.Header) {
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Robots-Tag", "noindex, nofollow")
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
boardwalk/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-ZNCn30hM.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -87,13 +87,16 @@ func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNam
return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames} return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames}
} }
// NewBackendJWTGuard is bearer-only and requires AudienceBackend. // NewBackendJWTGuard requires AudienceBackend. write may replace the
// write may replace the PHP-shaped 401 body; nil keeps write401. // PHP-shaped 401 body; nil keeps write401. With no cookieNames it is
func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard { // Bearer-only; otherwise each cookie is tried, in order, after the
// Authorization header, so a Bearer token still wins when both are sent.
func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error), cookieNames ...string) Guard {
return &jwtGuard{ return &jwtGuard{
secret: secret, secret: secret,
users: users, users: users,
bl: bl, bl: bl,
cookieNames: cookieNames,
audience: AudienceBackend, audience: AudienceBackend,
requireAudience: true, requireAudience: true,
writeFn: write, writeFn: write,

View File

@@ -1,9 +1,19 @@
package cabana package cabana
// Admin API annotations. swag reads these with the handler package so // @title SummerCMS Admin API
// docs/openapi.json lists every D-09 route. The functions are not mounted; // @version 1
// service.mount in http.go is the runtime route table, and // @description Framework admin API consumed by the embedded admin SPA. Every path is relative to {backend.uri}/api/v1 (for example /backend/api/v1). The SPA authenticates with the HttpOnly summer_admin cookie set by a login that sends X-Requested-With: XMLHttpRequest, and sends that header on every request; CLI clients and tests send the BackendBearer Authorization header instead.
// TestPhase09PermissionMatrix fails if the two lists diverge. // @BasePath /
// @securityDefinitions.apikey BackendBearer
// @in header
// @name Authorization
// @description Backend admin bearer token. Send "Bearer {access_token}".
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
// are generated from (D-15). The functions are not mounted; service.mount in
// http.go is the runtime route table, and TestPhase09PermissionMatrix plus
// TestPhase09ContractInventory fail if the two lists diverge.
// ErrorBody is one D-10 error object. // ErrorBody is one D-10 error object.
type ErrorBody struct { type ErrorBody struct {
@@ -32,41 +42,84 @@ type SuccessEnvelope struct {
Meta SuccessMeta `json:"meta"` Meta SuccessMeta `json:"meta"`
} }
// AdminLoginData is the admin login payload. // AdminLoginData is the admin login and refresh payload. Bearer transport
// carries access_token; cookie transport (X-Requested-With: XMLHttpRequest)
// carries token_type "cookie" and expires_in, never the token.
type AdminLoginData struct { type AdminLoginData struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token,omitempty"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in,omitempty"`
} }
// AdminLoginEnvelope is the admin login success body. // Envelope is the typed D-10 success envelope.
type AdminLoginEnvelope struct { type Envelope[T any] struct {
Data AdminLoginData `json:"data"` Data T `json:"data"`
Meta SuccessMeta `json:"meta"` Meta SuccessMeta `json:"meta"`
} }
// AdminLogin documents POST /_admin/api/v1/auth/login. // ListEnvelope is the typed D-10 paginated envelope (Phase 9 D-11 meta).
type ListEnvelope[T any] struct {
Data T `json:"data"`
Meta ListMeta `json:"meta"`
}
// AdminRecord is one admin record: a string-keyed map read through its
// list or form schema (D-16).
type AdminRecord map[string]any
// AdminLoginRequest is the admin login body. Either login or email
// identifies the backend user.
type AdminLoginRequest struct {
Login string `json:"login,omitempty"`
Email string `json:"email,omitempty"`
Password string `json:"password"`
}
// AdminRoleSummary is the role attached to an admin profile.
type AdminRoleSummary struct {
ID uint `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
// AdminProfile is the GET /auth/me payload.
type AdminProfile struct {
ID uint `json:"id"`
Login string `json:"login"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
IsSuperuser bool `json:"is_superuser"`
Role *AdminRoleSummary `json:"role,omitempty"`
}
// AdminLogin documents POST /auth/login.
// //
// @Summary Admin login // @Summary Admin login
// @Tags admin // @Tags admin
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} AdminLoginEnvelope // @Param body body AdminLoginRequest true "Credentials"
// @Param X-Requested-With header string false "XMLHttpRequest selects cookie transport"
// @Success 200 {object} Envelope[AdminLoginData]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/login [post] // @Router /auth/login [post]
func AdminLogin() {} func AdminLogin() {}
// AdminRefresh documents POST /_admin/api/v1/auth/refresh. // AdminRefresh documents POST /auth/refresh.
// //
// @Summary Refresh an admin token // @Summary Refresh an admin token
// @Tags admin // @Tags admin
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} AdminLoginEnvelope // @Param X-Requested-With header string false "XMLHttpRequest; required unless a Bearer token is sent"
// @Success 200 {object} Envelope[AdminLoginData]
// @Failure 403 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/refresh [post] // @Router /auth/refresh [post]
func AdminRefresh() {} func AdminRefresh() {}
// AdminLogout documents POST /_admin/api/v1/auth/logout. // AdminLogout documents POST /auth/logout.
// //
// @Summary Admin logout // @Summary Admin logout
// @Tags admin // @Tags admin
@@ -74,33 +127,33 @@ func AdminRefresh() {}
// @Security BackendBearer // @Security BackendBearer
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/logout [post] // @Router /auth/logout [post]
func AdminLogout() {} func AdminLogout() {}
// AdminMe documents GET /_admin/api/v1/auth/me. // AdminMe documents GET /auth/me.
// //
// @Summary Current admin // @Summary Current admin
// @Tags admin // @Tags admin
// @Produce json // @Produce json
// @Security BackendBearer // @Security BackendBearer
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} Envelope[AdminProfile]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/me [get] // @Router /auth/me [get]
func AdminMe() {} func AdminMe() {}
// AdminNavigation documents GET /_admin/api/v1/navigation. // AdminNavigation documents GET /navigation.
// //
// @Summary Admin navigation // @Summary Admin navigation
// @Tags admin // @Tags admin
// @Produce json // @Produce json
// @Security BackendBearer // @Security BackendBearer
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} Envelope[[]NavigationEntry]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/navigation [get] // @Router /navigation [get]
func AdminNavigation() {} func AdminNavigation() {}
// AdminSettingsList documents GET /_admin/api/v1/settings. // AdminSettingsList documents GET /settings.
// //
// @Summary List admin settings // @Summary List admin settings
// @Tags admin // @Tags admin
@@ -109,10 +162,10 @@ func AdminNavigation() {}
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings [get] // @Router /settings [get]
func AdminSettingsList() {} func AdminSettingsList() {}
// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema. // AdminSettingsSchema documents GET /settings/{code}/schema.
// //
// @Summary Admin settings schema // @Summary Admin settings schema
// @Tags admin // @Tags admin
@@ -123,10 +176,10 @@ func AdminSettingsList() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code}/schema [get] // @Router /settings/{code}/schema [get]
func AdminSettingsSchema() {} func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}. // AdminSettingsGet documents GET /settings/{code}.
// //
// @Summary Read admin settings // @Summary Read admin settings
// @Tags admin // @Tags admin
@@ -137,10 +190,10 @@ func AdminSettingsSchema() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [get] // @Router /settings/{code} [get]
func AdminSettingsGet() {} func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}. // AdminSettingsPut documents PUT /settings/{code}.
// //
// @Summary Update admin settings // @Summary Update admin settings
// @Tags admin // @Tags admin
@@ -152,7 +205,7 @@ func AdminSettingsGet() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [put] // @Router /settings/{code} [put]
func AdminSettingsPut() {} func AdminSettingsPut() {}
// AdminListSchema documents the list schema route. // AdminListSchema documents the list schema route.
@@ -164,11 +217,11 @@ func AdminSettingsPut() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} Envelope[ListSchema]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get] // @Router /{vendor}/{plugin}/{controller}/schema/list [get]
func AdminListSchema() {} func AdminListSchema() {}
// AdminFormSchema documents the form schema route. // AdminFormSchema documents the form schema route.
@@ -184,7 +237,7 @@ func AdminListSchema() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get] // @Router /{vendor}/{plugin}/{controller}/schema/form [get]
func AdminFormSchema() {} func AdminFormSchema() {}
// AdminRelationSchema documents the relation schema route. // AdminRelationSchema documents the relation schema route.
@@ -201,7 +254,7 @@ func AdminFormSchema() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get] // @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
func AdminRelationSchema() {} func AdminRelationSchema() {}
// AdminList documents the record list route. // AdminList documents the record list route.
@@ -213,11 +266,16 @@ func AdminRelationSchema() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope // @Param search query string false "Search term"
// @Param sort query string false "Sort column"
// @Param dir query string false "Sort direction (asc or desc)"
// @Param page query integer false "Page"
// @Param per_page query integer false "Records per page"
// @Success 200 {object} ListEnvelope[[]AdminRecord]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get] // @Router /{vendor}/{plugin}/{controller} [get]
func AdminList() {} func AdminList() {}
// AdminCreate documents the record create route. // AdminCreate documents the record create route.
@@ -234,7 +292,7 @@ func AdminList() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post] // @Router /{vendor}/{plugin}/{controller} [post]
func AdminCreate() {} func AdminCreate() {}
// AdminBulkDelete documents the bulk delete route. // AdminBulkDelete documents the bulk delete route.
@@ -251,7 +309,7 @@ func AdminCreate() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post] // @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
func AdminBulkDelete() {} func AdminBulkDelete() {}
// AdminShow documents the record show route. // AdminShow documents the record show route.
@@ -268,7 +326,7 @@ func AdminBulkDelete() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get] // @Router /{vendor}/{plugin}/{controller}/{id} [get]
func AdminShow() {} func AdminShow() {}
// AdminUpdate documents the record update route. // AdminUpdate documents the record update route.
@@ -286,7 +344,7 @@ func AdminShow() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put] // @Router /{vendor}/{plugin}/{controller}/{id} [put]
func AdminUpdate() {} func AdminUpdate() {}
// AdminDelete documents the record delete route. // AdminDelete documents the record delete route.
@@ -303,7 +361,7 @@ func AdminUpdate() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete] // @Router /{vendor}/{plugin}/{controller}/{id} [delete]
func AdminDelete() {} func AdminDelete() {}
// AdminRelationLinked documents the linked-relation route. // AdminRelationLinked documents the linked-relation route.
@@ -320,7 +378,7 @@ func AdminDelete() {}
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get] // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
func AdminRelationLinked() {} func AdminRelationLinked() {}
// AdminRelationCandidates documents the relation candidate route. // AdminRelationCandidates documents the relation candidate route.
@@ -337,7 +395,7 @@ func AdminRelationLinked() {}
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get] // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
func AdminRelationCandidates() {} func AdminRelationCandidates() {}
// AdminRelationLink documents the relation link route. // AdminRelationLink documents the relation link route.
@@ -356,7 +414,7 @@ func AdminRelationCandidates() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post] // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
func AdminRelationLink() {} func AdminRelationLink() {}
// AdminRelationUnlink documents the relation unlink route. // AdminRelationUnlink documents the relation unlink route.
@@ -375,5 +433,5 @@ func AdminRelationLink() {}
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope // @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post] // @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
func AdminRelationUnlink() {} func AdminRelationUnlink() {}

View File

@@ -0,0 +1,7 @@
package cabana
// adminAPI composes a full admin API path under the default prefix, so tests
// follow the backend.uri scheme (D-03) instead of hardcoding it.
func adminAPI(rel string) string {
return DefaultAdminPrefix + adminAPIVersion + rel
}

View File

@@ -172,12 +172,39 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
return return
} }
s.logAuth(r, "success", user.ID) s.logAuth(r, "success", user.ID)
if isAjax(r) {
// Cookie transport (D-19): the SPA never sees the token.
s.writeSessionCookie(w, token)
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
return
}
WriteData(w, http.StatusOK, map[string]string{ WriteData(w, http.StatusOK, map[string]string{
"access_token": token, "access_token": token,
"token_type": "bearer", "token_type": "bearer",
}, map[string]any{}) }, map[string]any{})
} }
// cookieLoginData is the login/refresh body under cookie transport: no token,
// only its type and the access lifetime in seconds.
func cookieLoginData(ttl time.Duration) AdminLoginData {
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
}
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
// Max-Age is the refresh window, because refresh accepts an expired access
// token until iat plus refresh_ttl.
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: AdminCookieName,
Value: token,
Path: s.adminPrefix(),
MaxAge: int(s.refreshTTL / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *service) refresh(w http.ResponseWriter, r *http.Request) { func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
raw := bearerToken(r) raw := bearerToken(r)
if raw == "" { if raw == "" {
@@ -247,23 +274,19 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
WriteData(w, http.StatusOK, profileOf(user), map[string]any{}) WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
} }
func profileOf(user BackendUser) map[string]any { func profileOf(user BackendUser) AdminProfile {
data := map[string]any{ profile := AdminProfile{
"id": user.ID, ID: user.ID,
"login": user.Login, Login: user.Login,
"email": user.Email, Email: user.Email,
"first_name": user.FirstName, FirstName: user.FirstName,
"last_name": user.LastName, LastName: user.LastName,
"is_superuser": user.IsSuperuser, IsSuperuser: user.IsSuperuser,
} }
if user.Role.ID != 0 { if user.Role.ID != 0 {
data["role"] = map[string]any{ profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
"id": user.Role.ID,
"code": user.Role.Code,
"name": user.Role.Name,
}
} }
return data return profile
} }
func bearerToken(r *http.Request) string { func bearerToken(r *http.Request) string {
@@ -402,15 +425,18 @@ func adminLoginWindow(app *backpack.App) (int, int) {
return maxAttempts, decayMinutes return maxAttempts, decayMinutes
} }
func adminIssuer(app *backpack.App) string { // adminIssuer is app.url plus the admin API login path. JWT verification does
// not check iss, so tokens minted under an earlier prefix stay valid until
// they expire.
func adminIssuer(app *backpack.App, prefix string) string {
base := "" base := ""
if app != nil && app.Config != nil { if app != nil && app.Config != nil {
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/") base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
} }
if base == "" { if prefix == "" {
return "/_admin/api/v1/auth/login" prefix = DefaultAdminPrefix
} }
return base + "/_admin/api/v1/auth/login" return base + prefix + adminAPIVersion + "/auth/login"
} }
// dummyPasswordHash keeps a missing-user login on the bcrypt path. // dummyPasswordHash keeps a missing-user login on the bcrypt path.

View File

@@ -42,7 +42,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
gdb := adminGorm(t) gdb := adminGorm(t)
h := adminHandler(t, gdb, nil) h := adminHandler(t, gdb, nil)
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false) user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
login := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{ login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "life", "login": "life",
"password": adminTestPassword, "password": adminTestPassword,
}) })
@@ -63,7 +63,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if !stamped.Valid { if !stamped.Valid {
t.Fatal("successful login did not stamp last_login") t.Fatal("successful login did not stamp last_login")
} }
byEmail := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{ byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"email": "life@example.test", "email": "life@example.test",
"password": adminTestPassword, "password": adminTestPassword,
}) })
@@ -71,12 +71,12 @@ func TestAdminAuthLifecycle(t *testing.T) {
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String()) t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
} }
emailToken := accessToken(t, byEmail.Body.Bytes()) emailToken := accessToken(t, byEmail.Body.Bytes())
me := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken) me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
if me.Code != http.StatusOK { if me.Code != http.StatusOK {
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String()) t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
} }
assertSafeProfile(t, me.Body.Bytes(), user) assertSafeProfile(t, me.Body.Bytes(), user)
refreshed := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", emailToken, nil) refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
if refreshed.Code != http.StatusOK { if refreshed.Code != http.StatusOK {
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String()) t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
} }
@@ -87,7 +87,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if jwtAudience(t, next) != "backend" { if jwtAudience(t, next) != "backend" {
t.Fatal("refreshed token lost the backend audience") t.Fatal("refreshed token lost the backend audience")
} }
oldMe := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken) oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
if oldMe.Code != http.StatusUnauthorized { if oldMe.Code != http.StatusUnauthorized {
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String()) t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
} }
@@ -98,14 +98,14 @@ func TestAdminAuthLifecycle(t *testing.T) {
if blacklisted != 1 { if blacklisted != 1 {
t.Fatalf("previous jti blacklist rows=%d", blacklisted) t.Fatalf("previous jti blacklist rows=%d", blacklisted)
} }
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", next, nil) out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
if out.Code != http.StatusOK { if out.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String()) t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
} }
if strings.Contains(out.Body.String(), next) { if strings.Contains(out.Body.String(), next) {
t.Fatal("logout body contains the token") t.Fatal("logout body contains the token")
} }
after := getAuth(t, h, "/_admin/api/v1/auth/me", next) after := getAuth(t, h, adminAPI("/auth/me"), next)
if after.Code != http.StatusUnauthorized { if after.Code != http.StatusUnauthorized {
t.Fatalf("me after logout status=%d", after.Code) t.Fatalf("me after logout status=%d", after.Code)
} }
@@ -113,7 +113,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil { if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
fresh := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{ fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "life", "password": adminTestPassword, "login": "life", "password": adminTestPassword,
}).Body.Bytes()) }).Body.Bytes())
// Login mints after the cutoff, so this token is current. Move the cutoff // Login mints after the cutoff, so this token is current. Move the cutoff
@@ -121,7 +121,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil { if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
t.Fatal(err) t.Fatal(err)
} }
stale := getAuth(t, h, "/_admin/api/v1/auth/me", fresh) stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
if stale.Code != http.StatusUnauthorized { if stale.Code != http.StatusUnauthorized {
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String()) t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
} }
@@ -131,9 +131,9 @@ func TestAdminInactive(t *testing.T) {
gdb := adminGorm(t) gdb := adminGorm(t)
h := adminHandler(t, gdb, nil) h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false) insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword}) unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"}) wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
right := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": adminTestPassword}) right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
assertSameOpaque(t, unknown, wrong, right) assertSameOpaque(t, unknown, wrong, right)
var stamped sql.NullTime var stamped sql.NullTime
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil { if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
@@ -148,8 +148,8 @@ func TestAdminDeleted(t *testing.T) {
gdb := adminGorm(t) gdb := adminGorm(t)
h := adminHandler(t, gdb, nil) h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true) insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword}) unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
deleted := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "deleted", "password": adminTestPassword}) deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
assertSameOpaque(t, unknown, deleted) assertSameOpaque(t, unknown, deleted)
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") { if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String()) t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
@@ -160,14 +160,14 @@ func TestAdminBlacklist(t *testing.T) {
gdb := adminGorm(t) gdb := adminGorm(t)
h := adminHandler(t, gdb, nil) h := adminHandler(t, gdb, nil)
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false) insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
token := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{ token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "revoke", "password": adminTestPassword, "login": "revoke", "password": adminTestPassword,
}).Body.Bytes()) }).Body.Bytes())
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", token, nil) out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
if out.Code != http.StatusOK { if out.Code != http.StatusOK {
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String()) t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
} }
again := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", token, nil) again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
if again.Code != http.StatusUnauthorized { if again.Code != http.StatusUnauthorized {
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String()) t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
} }
@@ -192,7 +192,7 @@ func TestAdminLoginThrottle(t *testing.T) {
}) })
var last *httptest.ResponseRecorder var last *httptest.ResponseRecorder
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
last = postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{ last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
"login": "throttle-user", "password": adminTestPassword, "login": "throttle-user", "password": adminTestPassword,
}) })
} }
@@ -217,16 +217,16 @@ func TestAdminAuthLogging(t *testing.T) {
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) }) t.Cleanup(func() { slog.SetDefault(prev) })
ok := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": adminTestPassword}) ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
token := accessToken(t, ok.Body.Bytes()) token := accessToken(t, ok.Body.Bytes())
assertLog(t, &buf, "success", user.ID, adminTestPassword, token) assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
buf.Reset() buf.Reset()
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": "not-the-password"}) postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
assertLog(t, &buf, "failed", user.ID, "not-the-password", "") assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
buf.Reset() buf.Reset()
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "missing-logged", "password": "not-the-password"}) postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
failedUnknown := buf.String() failedUnknown := buf.String()
if !strings.Contains(failedUnknown, `"outcome":"failed"`) { if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
t.Fatalf("unknown login log = %s", failedUnknown) t.Fatalf("unknown login log = %s", failedUnknown)
@@ -236,7 +236,7 @@ func TestAdminAuthLogging(t *testing.T) {
} }
buf.Reset() buf.Reset()
denied := getAuth(t, h, "/_admin/api/v1/acme/demo/widgets", token) denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
if denied.Code != http.StatusForbidden { if denied.Code != http.StatusForbidden {
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String()) t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
} }
@@ -551,3 +551,9 @@ func jwtClaims(t *testing.T, token string) map[string]any {
func itoa(id uint) string { func itoa(id uint) string {
return strconv.FormatUint(uint64(id), 10) return strconv.FormatUint(uint64(id), 10)
} }
// adminAPI mirrors the internal helper in admin_paths_test.go for this
// external test package: a full admin API path under the default prefix.
func adminAPI(rel string) string {
return cabana.DefaultAdminPrefix + "/api/v1" + rel
}

View File

@@ -18,7 +18,7 @@ import (
func TestBulkDeleteEmpty(t *testing.T) { func TestBulkDeleteEmpty(t *testing.T) {
cap := &captureRouter{} cap := &captureRouter{}
(&service{}).mount(cap) (&service{}).mount(cap)
key := "POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete" key := "POST " + adminAPI("/{vendor}/{plugin}/{controller}/bulk-delete")
mw, ok := cap.middleware[key] mw, ok := cap.middleware[key]
if !ok || !containsString(mw, "backend") { if !ok || !containsString(mw, "backend") {
t.Fatalf("missing %s in %v", key, cap.routes) t.Fatalf("missing %s in %v", key, cap.routes)

View File

@@ -107,7 +107,7 @@ func TestAdminResetPasswordCommand(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, nil) guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/auth/me", nil) req := httptest.NewRequest(http.MethodGet, adminAPI("/auth/me"), nil)
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
if _, err := guard.Authenticate(req); err != nil { if _, err := guard.Authenticate(req); err != nil {
t.Fatalf("token before reset: %v", err) t.Fatalf("token before reset: %v", err)

View File

@@ -104,10 +104,10 @@ func TestCRUDRecordRoutes(t *testing.T) {
cap := &captureRouter{} cap := &captureRouter{}
(&service{}).mount(cap) (&service{}).mount(cap)
for _, want := range []string{ for _, want := range []string{
"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", "POST " + adminAPI("/{vendor}/{plugin}/{controller}"),
"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", "GET " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", "PUT " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", "DELETE " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
} { } {
mw, ok := cap.middleware[want] mw, ok := cap.middleware[want]
if !ok { if !ok {

45
cabana/csrf.go Normal file
View File

@@ -0,0 +1,45 @@
package cabana
import (
"net/http"
"strings"
)
const (
// requestedWithHeader is the custom header the admin SPA sends on every
// request. A cross-site form or navigation cannot set it, and a
// cross-origin fetch that sets it needs a CORS preflight the admin API
// never answers (D-19).
requestedWithHeader = "X-Requested-With"
requestedWithAjax = "XMLHttpRequest"
)
// requireAjax refuses a state-changing admin request that is not
// Bearer-authenticated and does not carry X-Requested-With: XMLHttpRequest.
// It runs before the wrapped handler, so a refused request is never decoded,
// never looks up a controller and never reaches the database. The response
// uses the fixed D-10 code forbidden.
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !csrfSafe(r) {
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
return
}
next(w, r)
}
}
func csrfSafe(r *http.Request) bool {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
if bearerToken(r) != "" {
return true
}
return isAjax(r)
}
func isAjax(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get(requestedWithHeader)) == requestedWithAjax
}

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"git.golem15.com/golem15/summercms/backpack" "git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/boardwalk"
"git.golem15.com/golem15/summercms/bouncer" "git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party" "git.golem15.com/golem15/summercms/party"
@@ -20,10 +21,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Routes is the raw admin API mounted by surf.BuildRouter. // Routes is the raw admin API and SPA mounted by surf.BuildRouter. Prefix is
// the normalized backend.uri every admin route lives under.
type Routes struct { type Routes struct {
Middleware pact.Middleware Middleware pact.Middleware
Mount func(r pact.Router) Mount func(r pact.Router)
Prefix string
} }
type service struct { type service struct {
@@ -38,6 +41,21 @@ type service struct {
loginDecay int loginDecay int
issuer string issuer string
bl bouncer.BlacklistStore bl bouncer.BlacklistStore
prefix string
spa http.Handler
}
// adminPrefix returns the mount path; a zero service uses the default.
func (s *service) adminPrefix() string {
if s == nil || s.prefix == "" {
return DefaultAdminPrefix
}
return s.prefix
}
// apiBase is the admin API root: the prefix plus /api/v1 (D-03).
func (s *service) apiBase() string {
return s.adminPrefix() + adminAPIVersion
} }
// Activate compiles admin controllers and, when any exist, requires // Activate compiles admin controllers and, when any exist, requires
@@ -54,6 +72,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
prefix, err := AdminPrefix(app)
if err != nil {
return nil, err
}
reg, err := compileRegistry(items) reg, err := compileRegistry(items)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -72,7 +94,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
} }
} }
bl := adminBlacklist(app) bl := adminBlacklist(app)
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated) guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated, AdminCookieName)
if _, err := guards.Middleware("backend"); err != nil { if _, err := guards.Middleware("backend"); err != nil {
if err := guards.Register("summercms.cabana", "backend", guard); err != nil { if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
return nil, err return nil, err
@@ -93,10 +115,31 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
bcryptCost: adminBcryptCost(app), bcryptCost: adminBcryptCost(app),
loginMax: loginMax, loginMax: loginMax,
loginDecay: loginDecay, loginDecay: loginDecay,
issuer: adminIssuer(app), issuer: adminIssuer(app, prefix),
bl: bl, bl: bl,
prefix: prefix,
} }
return &Routes{Middleware: mw, Mount: svc.mount}, nil spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
if err != nil {
return nil, fmt.Errorf("cabana: admin SPA: %w", err)
}
svc.spa = spa
return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil
}
func writeNotFound(w http.ResponseWriter, _ *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
}
// serveSPA answers GET {prefix} and GET {prefix}/{path...} from the embedded
// build. API paths that no route matched fall through to it and receive the
// D-10 not_found envelope, never index.html.
func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) {
if s == nil || s.spa == nil {
writeNotFound(w, r)
return
}
s.spa.ServeHTTP(w, r)
} }
func writeUnauthenticated(w http.ResponseWriter, _ error) { func writeUnauthenticated(w http.ResponseWriter, _ error) {
@@ -121,12 +164,15 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
func (s *service) mount(r pact.Router) { func (s *service) mount(r pact.Router) {
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay) throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) { api := s.apiBase()
r.GroupRaw(api+"/auth", nil, func(g pact.Router) {
// Login is exempt from the CSRF header: without it the response is a
// Bearer body and no cookie is set, so a cross-site post gains nothing.
g.Post("/login", s.login, throttle) g.Post("/login", s.login, throttle)
g.Post("/refresh", s.refresh) g.Post("/refresh", requireAjax(s.refresh))
}) })
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) { r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", s.logout) g.Post("/auth/logout", requireAjax(s.logout))
g.Get("/auth/me", s.me) g.Get("/auth/me", s.me)
g.Get("/navigation", s.navigation) g.Get("/navigation", s.navigation)
g.Get("/settings", s.settingsList) g.Get("/settings", s.settingsList)
@@ -134,7 +180,7 @@ func (s *service) mount(r pact.Router) {
constrainSetting(g) constrainSetting(g)
g.Get("/settings/{code}", s.settingsGet) g.Get("/settings/{code}", s.settingsGet)
constrainSetting(g) constrainSetting(g)
g.Put("/settings/{code}", s.settingsPut) g.Put("/settings/{code}", requireAjax(s.settingsPut))
constrainSetting(g) constrainSetting(g)
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema) g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
constrainController(g) constrainController(g)
@@ -144,25 +190,31 @@ func (s *service) mount(r pact.Router) {
constrainRelation(g) constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}", s.list) g.Get("/{vendor}/{plugin}/{controller}", s.list)
constrainController(g) constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}", s.create) g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
constrainController(g) constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", s.bulkDelete) g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
constrainController(g) constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show) g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
constrainController(g) constrainController(g)
g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update) g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
constrainController(g) constrainController(g)
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord) g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
constrainController(g) constrainController(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked) g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
constrainRelation(g) constrainRelation(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates) g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
constrainRelation(g) constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
constrainRelation(g) constrainRelation(g)
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink) g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
constrainRelation(g) constrainRelation(g)
}) })
// The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard.
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
g.Get("", s.serveSPA)
g.Get("/{path...}", s.serveSPA)
})
} }
func constrainController(g pact.Router) { func constrainController(g pact.Router) {

View File

@@ -9,14 +9,15 @@ import (
"testing" "testing"
) )
// TestPhase09ContractInventory fails when the committed OpenAPI document // TestPhase09ContractInventory fails when the committed framework admin
// drops a D-09 route or a protected route's 401 response. // OpenAPI document (admin/openapi/admin.json, D-15) drops an admin API route
// or a protected route's 401 response. Paths are prefix-relative (D-03).
func TestPhase09ContractInventory(t *testing.T) { func TestPhase09ContractInventory(t *testing.T) {
_, file, _, ok := runtime.Caller(0) _, file, _, ok := runtime.Caller(0)
if !ok { if !ok {
t.Fatal("caller") t.Fatal("caller")
} }
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json")) specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.json"))
raw, err := os.ReadFile(specPath) raw, err := os.ReadFile(specPath)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", specPath, err) t.Fatalf("read %s: %v", specPath, err)
@@ -37,11 +38,16 @@ func TestPhase09ContractInventory(t *testing.T) {
t.Fatal("openapi is missing the BackendBearer scheme") t.Fatal("openapi is missing the BackendBearer scheme")
} }
public := map[string]bool{ public := map[string]bool{
"POST /_admin/api/v1/auth/login": true, "POST /auth/login": true,
"POST /_admin/api/v1/auth/refresh": true, "POST /auth/refresh": true,
} }
seen := map[string]bool{} seen := map[string]bool{}
apiRoutes := 0
for _, route := range phase09Routes { for _, route := range phase09Routes {
if route.spa {
continue
}
apiRoutes++
method, path, ok := splitRoute(route.key) method, path, ok := splitRoute(route.key)
if !ok { if !ok {
t.Fatalf("bad route key %s", route.key) t.Fatalf("bad route key %s", route.key)
@@ -76,8 +82,11 @@ func TestPhase09ContractInventory(t *testing.T) {
t.Fatalf("%s has no 401 response", key) t.Fatalf("%s has no 401 response", key)
} }
} }
if len(seen) != len(phase09Routes) { if len(seen) != apiRoutes {
t.Fatalf("contract routes=%d want %d", len(seen), len(phase09Routes)) t.Fatalf("contract routes=%d want %d", len(seen), apiRoutes)
}
if len(spec.Paths) != len(pathsOf(phase09Routes)) {
t.Fatalf("openapi lists %d paths, the mounted API has %d", len(spec.Paths), len(pathsOf(phase09Routes)))
} }
} }
@@ -89,3 +98,20 @@ func splitRoute(key string) (method, path string, ok bool) {
} }
return "", "", false return "", "", false
} }
func pathsOf(routes []struct {
key string
public bool
spa bool
}) map[string]bool {
out := map[string]bool{}
for _, route := range routes {
if route.spa {
continue
}
if _, path, ok := splitRoute(route.key); ok {
out[path] = true
}
}
return out
}

49
cabana/prefix.go Normal file
View File

@@ -0,0 +1,49 @@
package cabana
import (
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/backpack"
)
// DefaultAdminPrefix is the admin mount path when backend.uri is unset.
// It matches WinterCMS's backendUri default.
const DefaultAdminPrefix = "/backend"
// AdminCookieName carries the admin JWT for the embedded SPA (D-19).
const AdminCookieName = "summer_admin"
// adminAPIVersion is appended to the prefix for every admin API route.
const adminAPIVersion = "/api/v1"
var adminPrefixPattern = regexp.MustCompile(`^(/[a-z0-9][a-z0-9_-]*)+$`)
// AdminPrefix reads backend.uri and returns the normalized admin mount path.
// Spaces are trimmed, a leading slash is added and trailing slashes are
// removed; an empty value falls back to DefaultAdminPrefix. Every segment
// must be lowercase letters, digits, '-' or '_' and start with a letter or
// digit, so "/" alone, uppercase, spaces and dot segments are rejected.
func AdminPrefix(app *backpack.App) (string, error) {
raw := ""
if app != nil && app.Config != nil {
raw = app.Config.String("backend.uri")
}
return normalizeAdminPrefix(raw)
}
func normalizeAdminPrefix(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
return DefaultAdminPrefix, nil
}
if !strings.HasPrefix(value, "/") {
value = "/" + value
}
value = strings.TrimRight(value, "/")
if value == "" || !adminPrefixPattern.MatchString(value) {
return "", fmt.Errorf("cabana: backend.uri %q is invalid: use one or more lowercase path segments such as /backend (set SUMMER_BACKEND__URI)", raw)
}
return value, nil
}

View File

@@ -12,35 +12,49 @@ import (
"git.golem15.com/golem15/summercms/pact" "git.golem15.com/golem15/summercms/pact"
) )
// phase09Routes is the D-09 admin surface mounted by service.mount. // phase09Routes is the admin surface mounted by service.mount. API keys are
// A handler added outside this set, or a protected handler missing the // method plus the path relative to {backend.uri}/api/v1 (D-03); spa entries
// backend guard, fails TestPhase09PermissionMatrix. // are the public SPA shell routes relative to {backend.uri} and are not part
// of the OpenAPI inventory. A handler added outside this set, or a protected
// handler missing the backend guard, fails TestPhase09PermissionMatrix.
var phase09Routes = []struct { var phase09Routes = []struct {
key string key string
public bool public bool
spa bool
}{ }{
{"POST /_admin/api/v1/auth/login", true}, {"POST /auth/login", true, false},
{"POST /_admin/api/v1/auth/refresh", true}, {"POST /auth/refresh", true, false},
{"POST /_admin/api/v1/auth/logout", false}, {"POST /auth/logout", false, false},
{"GET /_admin/api/v1/auth/me", false}, {"GET /auth/me", false, false},
{"GET /_admin/api/v1/navigation", false}, {"GET /navigation", false, false},
{"GET /_admin/api/v1/settings", false}, {"GET /settings", false, false},
{"GET /_admin/api/v1/settings/{code}/schema", false}, {"GET /settings/{code}/schema", false, false},
{"GET /_admin/api/v1/settings/{code}", false}, {"GET /settings/{code}", false, false},
{"PUT /_admin/api/v1/settings/{code}", false}, {"PUT /settings/{code}", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list", false}, {"GET /{vendor}/{plugin}/{controller}/schema/list", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form", false}, {"GET /{vendor}/{plugin}/{controller}/schema/form", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name}", false}, {"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}", false}, {"GET /{vendor}/{plugin}/{controller}", false, false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", false}, {"POST /{vendor}/{plugin}/{controller}", false, false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete", false}, {"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false}, {"GET /{vendor}/{plugin}/{controller}/{id}", false, false},
{"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false}, {"PUT /{vendor}/{plugin}/{controller}/{id}", false, false},
{"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false}, {"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}", false}, {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false},
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false}, {"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false}, {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false},
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false}, {"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false},
{"GET ", true, true},
{"GET /{path...}", true, true},
}
// mountedKey is the full mounted route key for an inventory entry.
func mountedKey(key string, spa bool) string {
method, rel, _ := strings.Cut(key, " ")
if spa {
return method + " " + DefaultAdminPrefix + rel
}
return method + " " + adminAPI(rel)
} }
func TestPhase09PermissionMatrix(t *testing.T) { func TestPhase09PermissionMatrix(t *testing.T) {
@@ -57,9 +71,10 @@ func TestPhase09PermissionMatrix(t *testing.T) {
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes) t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
} }
for _, route := range phase09Routes { for _, route := range phase09Routes {
mw, ok := got[route.key] key := mountedKey(route.key, route.spa)
mw, ok := got[key]
if !ok { if !ok {
t.Fatalf("missing mounted route %s", route.key) t.Fatalf("missing mounted route %s in %v", key, router.routes)
} }
hasBackend := false hasBackend := false
for _, name := range mw { for _, name := range mw {
@@ -257,7 +272,7 @@ func phase09DeniedService() *service {
} }
func phase09Request(principal *bouncer.Principal) *http.Request { func phase09Request(principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/_admin/api/v1/acme/demo/widgets/1/relations/editors/link", strings.NewReader(`{}`)) req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/widgets/1/relations/editors/link"), strings.NewReader(`{}`))
req.SetPathValue("vendor", "acme") req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo") req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets") req.SetPathValue("controller", "widgets")

View File

@@ -77,7 +77,7 @@ func TestSecretRedaction(t *testing.T) {
} }
func controllerRequest(principal *bouncer.Principal) *http.Request { func controllerRequest(principal *bouncer.Principal) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/acme/demo/widgets", nil) req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets"), nil)
req.SetPathValue("vendor", "acme") req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo") req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "widgets") req.SetPathValue("controller", "widgets")

3
go.mod
View File

@@ -2,6 +2,9 @@ module git.golem15.com/golem15/summercms
go 1.27.0 go 1.27.0
// The admin SPA's npm tree may ship .go files; keep ./... out of it.
ignore ./admin/node_modules
require ( require (
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1

View File

@@ -0,0 +1,380 @@
// Command swagger2openapi converts swag v1's Swagger 2.0 JSON to OpenAPI 3.0
// so openapi-typescript 7.x can consume it. swag v1 has no OpenAPI 3 emitter
// (v2 is RC and rejected by STACK.md). It is a copy of the app repository's
// converter plus a rewrite of cabana's opaque JSON types into unions, used by
// scripts/check-admin-openapi.sh to build admin/openapi/admin.json (D-15).
package main
import (
"encoding/json"
"fmt"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "usage: swagger2openapi <swagger.json>\n")
os.Exit(2)
}
raw, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out := swagger2openapi(doc)
enc, err := json.MarshalIndent(out, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
enc = append(enc, '\n')
if _, err := os.Stdout.Write(enc); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func swagger2openapi(doc map[string]any) map[string]any {
out := map[string]any{
"openapi": "3.0.3",
}
if info, ok := doc["info"]; ok {
out["info"] = info
}
if tags, ok := doc["tags"]; ok {
out["tags"] = tags
}
if servers := convertServers(doc); len(servers) > 0 {
out["servers"] = servers
}
if paths, ok := doc["paths"].(map[string]any); ok {
out["paths"] = convertPaths(paths)
}
components := map[string]any{}
if defs, ok := doc["definitions"].(map[string]any); ok {
rewriteOpaque(defs)
components["schemas"] = defs
}
if sec, ok := doc["securityDefinitions"].(map[string]any); ok {
components["securitySchemes"] = convertSecurity(sec)
}
if len(components) > 0 {
out["components"] = components
}
if sec, ok := doc["security"]; ok {
out["security"] = sec
}
return rewriteRefs(out).(map[string]any)
}
func convertServers(doc map[string]any) []any {
host, _ := doc["host"].(string)
base, _ := doc["basePath"].(string)
schemes, _ := doc["schemes"].([]any)
if host == "" && (base == "" || base == "/") && len(schemes) == 0 {
return nil
}
if len(schemes) == 0 {
schemes = []any{"https"}
}
if base == "" {
base = "/"
}
out := make([]any, 0, len(schemes))
for _, s := range schemes {
scheme, _ := s.(string)
url := scheme + "://" + host
if host == "" {
url = base
} else if base != "/" {
url += base
}
out = append(out, map[string]any{"url": url})
}
return out
}
func convertPaths(paths map[string]any) map[string]any {
out := make(map[string]any, len(paths))
for path, raw := range paths {
item, ok := raw.(map[string]any)
if !ok {
out[path] = raw
continue
}
converted := make(map[string]any, len(item))
var produces []string
if p, ok := item["produces"].([]any); ok {
produces = stringList(p)
}
var consumes []string
if c, ok := item["consumes"].([]any); ok {
consumes = stringList(c)
}
for k, v := range item {
switch k {
case "produces", "consumes":
continue
case "get", "put", "post", "delete", "options", "head", "patch", "trace":
op, ok := v.(map[string]any)
if !ok {
converted[k] = v
continue
}
converted[k] = convertOperation(op, produces, consumes)
default:
converted[k] = v
}
}
out[path] = converted
}
return out
}
func convertOperation(op map[string]any, parentProduces, parentConsumes []string) map[string]any {
out := make(map[string]any, len(op))
produces := parentProduces
if p, ok := op["produces"].([]any); ok {
produces = stringList(p)
}
if len(produces) == 0 {
produces = []string{"application/json"}
}
consumes := parentConsumes
if c, ok := op["consumes"].([]any); ok {
consumes = stringList(c)
}
if len(consumes) == 0 {
consumes = []string{"application/json"}
}
for k, v := range op {
switch k {
case "produces", "consumes":
continue
case "parameters":
params, body := splitParameters(v, consumes)
if len(params) > 0 {
out["parameters"] = params
}
if body != nil {
out["requestBody"] = body
}
case "responses":
res, ok := v.(map[string]any)
if !ok {
out[k] = v
continue
}
out[k] = convertResponses(res, produces)
default:
out[k] = v
}
}
return out
}
func splitParameters(v any, consumes []string) (params []any, body map[string]any) {
list, ok := v.([]any)
if !ok {
return nil, nil
}
for _, item := range list {
p, ok := item.(map[string]any)
if !ok {
params = append(params, item)
continue
}
if in, _ := p["in"].(string); in == "body" {
content := map[string]any{}
for _, ct := range consumes {
entry := map[string]any{}
if schema, ok := p["schema"]; ok {
entry["schema"] = schema
}
content[ct] = entry
}
body = map[string]any{"content": content}
if req, ok := p["required"]; ok {
body["required"] = req
}
if desc, ok := p["description"]; ok {
body["description"] = desc
}
continue
}
params = append(params, convertParameter(p))
}
return params, body
}
var paramSchemaKeys = map[string]struct{}{
"type": {}, "format": {}, "items": {}, "enum": {}, "default": {},
"minimum": {}, "maximum": {}, "minLength": {}, "maxLength": {},
"pattern": {}, "uniqueItems": {}, "multipleOf": {},
"exclusiveMinimum": {}, "exclusiveMaximum": {},
"additionalProperties": {}, "properties": {},
}
func convertParameter(p map[string]any) map[string]any {
out := make(map[string]any, len(p))
schema := map[string]any{}
for k, v := range p {
if _, ok := paramSchemaKeys[k]; ok {
schema[k] = v
continue
}
out[k] = v
}
if len(schema) > 0 {
out["schema"] = schema
}
return out
}
func convertResponses(res map[string]any, produces []string) map[string]any {
out := make(map[string]any, len(res))
for code, raw := range res {
r, ok := raw.(map[string]any)
if !ok {
out[code] = raw
continue
}
converted := make(map[string]any, len(r))
var schema any
for k, v := range r {
if k == "schema" {
schema = v
continue
}
converted[k] = v
}
if schema != nil {
content := map[string]any{}
for _, ct := range produces {
content[ct] = map[string]any{"schema": schema}
}
converted["content"] = content
}
out[code] = converted
}
return out
}
func convertSecurity(sec map[string]any) map[string]any {
out := make(map[string]any, len(sec))
for name, raw := range sec {
s, ok := raw.(map[string]any)
if !ok {
out[name] = raw
continue
}
copied := make(map[string]any, len(s))
for k, v := range s {
copied[k] = v
}
if t, _ := copied["type"].(string); t == "oauth2" {
flows := map[string]any{}
flow, _ := copied["flow"].(string)
delete(copied, "flow")
flowObj := map[string]any{}
if u, ok := copied["authorizationUrl"]; ok {
flowObj["authorizationUrl"] = u
delete(copied, "authorizationUrl")
}
if u, ok := copied["tokenUrl"]; ok {
flowObj["tokenUrl"] = u
delete(copied, "tokenUrl")
}
if sc, ok := copied["scopes"]; ok {
flowObj["scopes"] = sc
delete(copied, "scopes")
}
switch flow {
case "implicit":
flows["implicit"] = flowObj
case "password":
flows["password"] = flowObj
case "application":
flows["clientCredentials"] = flowObj
case "accessCode":
flows["authorizationCode"] = flowObj
}
copied["flows"] = flows
}
out[name] = copied
}
return out
}
func rewriteRefs(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if k == "$ref" {
if s, ok := val.(string); ok {
const from = "#/definitions/"
const to = "#/components/schemas/"
if len(s) >= len(from) && s[:len(from)] == from {
out[k] = to + s[len(from):]
continue
}
}
}
out[k] = rewriteRefs(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = rewriteRefs(val)
}
return out
default:
return v
}
}
func stringList(in []any) []string {
out := make([]string, 0, len(in))
for _, v := range in {
s, ok := v.(string)
if ok && s != "" {
out = append(out, s)
}
}
return out
}
// opaqueUnions replaces definitions that swag can only see as an empty object
// because their Go type marshals itself. cabana.jsonScalar is a string,
// number, boolean or null; cabana.fieldContext is a string or string list.
var opaqueUnions = map[string]map[string]any{
"cabana.jsonScalar": {
"nullable": true,
"oneOf": []any{
map[string]any{"type": "string"},
map[string]any{"type": "number"},
map[string]any{"type": "boolean"},
},
},
"cabana.fieldContext": {
"oneOf": []any{
map[string]any{"type": "string"},
map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
},
},
}
func rewriteOpaque(defs map[string]any) {
for name, union := range opaqueUnions {
if _, ok := defs[name]; ok {
defs[name] = union
}
}
}

60
scripts/check-admin-openapi.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Generate the framework admin OpenAPI document and the admin SPA's TypeScript
# types from the swag annotations in cabana (D-15):
# swag v1.16.6 (Swagger 2.0) -> internal/tools/swagger2openapi (OpenAPI 3.0)
# -> openapi-typescript (admin devDependency) -> admin/src/api/schema.d.ts
# Without arguments the outputs replace admin/openapi/admin.json and
# admin/src/api/schema.d.ts. With --check nothing is written and the script
# exits non-zero when either committed file differs from a fresh generation.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
CHECK=0
case "${1:-}" in
"") ;;
--check) CHECK=1 ;;
*)
echo "usage: scripts/check-admin-openapi.sh [--check]" >&2
exit 2
;;
esac
if [[ ! -d admin/node_modules ]]; then
npm --prefix admin ci
fi
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
go run github.com/swaggo/swag/cmd/swag@v1.16.6 init \
--dir cabana \
--generalInfo admin_openapi.go \
--output "$TMP" \
--outputTypes json \
--requiredByDefault \
--quiet
if [[ ! -s "$TMP/swagger.json" ]]; then
echo "check-admin-openapi: swag did not generate swagger.json" >&2
exit 1
fi
go run ./internal/tools/swagger2openapi "$TMP/swagger.json" > "$TMP/admin.json"
admin/node_modules/.bin/openapi-typescript "$TMP/admin.json" -o "$TMP/schema.d.ts"
if [[ "$CHECK" -eq 1 ]]; then
status=0
diff -u admin/openapi/admin.json "$TMP/admin.json" || status=1
diff -u admin/src/api/schema.d.ts "$TMP/schema.d.ts" || status=1
if [[ "$status" -ne 0 ]]; then
echo "check-admin-openapi: committed admin OpenAPI output is stale; run scripts/check-admin-openapi.sh" >&2
fi
exit "$status"
fi
mkdir -p admin/openapi admin/src/api
cp "$TMP/admin.json" admin/openapi/admin.json
cp "$TMP/schema.d.ts" admin/src/api/schema.d.ts
echo "check-admin-openapi: wrote admin/openapi/admin.json and admin/src/api/schema.d.ts"