Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	frontend/src/views/settings/SiteSettings.vue
This commit is contained in:
benjamin.baker
2026-08-27 05:45:52 -04:00
69 changed files with 5601 additions and 327 deletions

View File

@@ -12,6 +12,104 @@ ADR-007 and ADR-002.
### Added
- **Audit log retention.** The audit log is append-only and nothing ever removed
a row, so it grew for as long as a site ran - here to the point where the
audit page was tens of thousands of pages deep and therefore useless, because
nobody pages through that to find anything. Site & Facility gains
`audit_retentiondays` plus a card showing the log's size and exactly how many
entries a purge would remove, and there is `flask audit stats` /
`flask audit purge --apply` for the server. **Opt-in: the default is 0, which
keeps everything.** An upgrade that silently started deleting a site's audit
history would be indefensible. Deletion runs in committed batches, because one
`DELETE` over a million rows holds table locks long enough to block the
collectors posting inventory; a run that stops early reports how much is left
rather than claiming it finished. Purging needs its own `audit.purge`
permission - reading the log and destroying it are different acts.
- **Guides: images, and a reader who cannot see drafts.** Guide images are
fetched through the authenticated API client instead of being pointed at by
`<img src>`. An image tag sends no Authorization header and this app keeps its
JWT in one, so every guide image was a broken icon for every user; the same
change makes stored image links survive a subpath mount. A failed image now
says so in words rather than showing a broken icon. Revision and image routes
are gated on their parent guide - revision ids are sequential, so reading one
guide used to be enough to walk the ids and read every unpublished draft on
the site. Search, the guide list and the tag chips now match the PUBLISHED
text, not the working copy, so a reader can no longer get a hit on wording
that is not in the page when they open it.
### Fixed
- **A guide's URL no longer moves when its title is edited.** The slug was
regenerated on every save that carried a title, and the editor autosaves every
three seconds - so fixing a typo in a heading silently moved the guide and
broke exactly the pasted links the stored slug exists to protect. The slug is
now set once, at create.
- **Publishing a guide after a failed save reported success.** `publish()` ran
regardless of whether the pre-publish save reached the server, so the author
saw "Published version N" while readers got the previous revision. A failed
save now stops the publish and says why.
- **The ADR-015 site-literal check was enforcing nothing.** Its patterns are
assembled from fragments so the published script does not contain the strings
it greps for, but they used the apostrophe-escape form (`'a'\''b'`) instead of
concatenation (`'a''b'`), embedding a literal apostrophe in every pattern.
Nothing could ever match and the check passed everything from the day it was
written. Fixed, and it immediately found 25 real violations - among them a QA
ServiceNow tenant URL sitting in the shipped defaults in seven places.
- **ServiceNow ships off and blank.** The defaults pointed at one organisation's
QA ServiceNow tenant with the integration enabled, so a site that had never
configured ServiceNow still rendered ticket links - to a tenant its
technicians cannot reach, and for an adopting site, into somebody else's
instance. There is no cross-site default for "which ServiceNow is yours".
- **Credentials are no longer stored in backup revisions.** The NTLARS/DNC
registry carries an FTP password in cleartext, and it was stored verbatim -
rendered by the revision panel and handed back by the download route as a
ready-to-import `.reg`. Credential values are now redacted at parse, so they
never reach the database, and the rendered file NAMES each withheld value
instead of carrying a placebo one: importing a fake password would produce a
machine that looks restored and cannot connect. `scripts/redact_backup_secrets.py`
scrubs revisions recorded before this (dry run by default). Rotate any
password that was stored.
- **A search domain that fails is no longer indistinguishable from one that
found nothing.** Each domain catches broadly and returns no rows so one broken
table cannot take the whole search box down - but the technician was then told
they might have misspelled a query that was right, and concluded the record
did not exist. Failed domains are named in the response, shown as a banner,
and suppress the did-you-mean.
- **An empty `sourcehostname` defeated backup dedup permanently.** The dedup
query maps `''` to `IS NULL` while the insert stored the raw `''`, so those
rows could never match their own chain: every post inserted, the table grew
without bound and the history filled with identical entries. Normalized once
at ingest, whitespace included.
- **`printedparts` migration 0003 was unguarded.** Unlike its twenty siblings it
ran a bare `ALTER`, so `flask plugin upgrade-all` failed on any database built
by `create_all` and took 0004 down with it. The migration test that should
have caught this only covered single-migration chains; it now covers every
post-cutover chain.
- **Guide links with several parameters opened the wrong page.** Text is escaped
before formatting, so a captured URL arrives carrying entities and was then
escaped a second time - `&amp;amp;` in every multi-parameter ServiceNow and
SharePoint link. Emphasis could also reach inside an `href`, and
`//host` passed as an in-app link when the browser reads it as off-site.
- **Retire an asset from the interface.** Machines, PCs, printers and measuring
tools had no way to take a record out of ShopDB without calling the API, so a
scrapped tool stayed in every list and report. The endpoints already existed
and were already soft deletes: the record leaves the lists while its
calibration history, backups and relationships stay put, and it can be
restored by setting `isactive` back. Called Retire rather than Delete for
that reason, and the confirm says what is kept. USB is not included - it
retires through a status column in the external CMMC database and needs its
own decision.
- **`flask catalog dedupe`, `merge` and `rename`: catalog hygiene.** The legacy
data carried space-stripped twins - `DellInc.` beside `Dell Inc.`,
`Windows10` beside `Windows 10` - which rode into the shipped seed catalog
@@ -90,6 +188,24 @@ ADR-007 and ADR-002.
### Fixed
- **Every detail page had the same browser tab title.** A machine, a PC and a
printer all read "West Jefferson ShopDB - Machine" or the equivalent, so two
tabs on two different records were indistinguishable, a bookmark said nothing
about what it pointed at, and a link shared into Teams previewed as the
section rather than the thing. Detail pages now name the record: machines,
PCs, printers, network devices, subnets, measuring tools, USB devices,
printed parts, applications, employees, knowledge base articles and guides.
A record with no usable name, or one that failed to load, keeps the section
name, and a long name is cut at a word boundary. Report pages gained proper
titles too, which also fixes "Pc Relationships".
- **Guides would have been dropped at provisioning.** The plugin was missing
from `deploy/site-profile-universal.json`, the profile the released Windows
installer is built from, so the wizard would not have offered it and
`flask plugin prune-schema` drops the tables of any plugin absent from the
profile (ADR-014). Its model also imported core directly rather than through
`shopdb.api`, which the contract test catches.
- **The shipped catalog offered two ways to spell the same vendor, and one row
that should never have left a test.** `HP Inc.` and `Zebra Technologies` were
empty rows duplicating `HP` and `Zebra`, which carry every model between

View File

@@ -5,6 +5,7 @@
"computers",
"employees",
"geenforce",
"guides",
"knowledgebase",
"machines",
"measuringtools",

View File

@@ -359,6 +359,11 @@ export const printersApi = {
update(id, data) {
return api.put(`/printers/${id}`, data)
},
// Soft delete: the server deactivates the asset, so the printer leaves every
// list while its history and relationships stay intact.
remove(id) {
return api.delete(`/printers/${id}`)
},
updateExtension(id, data) {
return api.put(`/printers/${id}/printerdata`, data)
},

View File

@@ -0,0 +1,91 @@
// The browser tab title, including WHICH record is open.
//
// The router can only name the KIND of page, because it runs before the view
// has fetched anything: every application read "<facility> ShopDB -
// Application", so two tabs on two different machines were indistinguishable,
// and a shared link previewed as the section rather than the thing.
//
// So a detail view names itself once its record arrives:
//
// setRecordTitle(machine.assetnumber)
//
// THE ORDERING IS THE WHOLE PROBLEM. The router's afterEach awaits the site
// settings before it can build "<facility> ShopDB - <page>", so on a cold load
// it finishes AFTER a view that fetched quickly has already set a good title,
// and would overwrite it with the generic one. Both writers therefore go
// through here, and the generic write is dropped when a record title already
// belongs to the route being landed on. The key is the route's fullPath, so
// navigating away invalidates the previous record's claim.
import { getFacilityName } from '@/utils/siteSettings'
let activeKey = null // fullPath of the route the tab is currently showing
let recordTitle = null // record name set by a view for activeKey, if any
let sitePrefix = 'ShopDB' // '<facility> ShopDB', resolved once and cached
function compose(page) {
return page && page !== 'Home' ? `${sitePrefix} - ${page}` : sitePrefix
}
async function resolveSitePrefix() {
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') sitePrefix = `${facility} ShopDB`
} catch (settingsError) {
// settings unavailable: plain ShopDB is a fine tab title
}
return sitePrefix
}
// Called by the router on every navigation, BEFORE it awaits anything, so a
// view that resolves first is not treated as belonging to the previous route.
export function beginNavigation(key) {
activeKey = key
recordTitle = null
}
// Called by the router once it knows the site prefix. Skipped when a view has
// already named the record for this route.
export async function applyGenericTitle(key, page) {
await resolveSitePrefix()
if (key !== activeKey) return // navigated on while we awaited
// A view that named the record before the facility name arrived composed its
// title against the placeholder prefix, so rewrite it rather than leaving
// 'ShopDB - CSF16' without the facility name in front of it.
document.title = compose(recordTitle !== null ? recordTitle : page)
}
// A tab shows very little, and the browser truncates mid-word wherever it runs
// out. Cut at a word boundary instead, so what survives is readable. Knowledge
// base articles are the case that forces this: their name is a whole sentence
// of description, not a short label.
const MAXLENGTH = 60
function shorten(text) {
if (text.length <= MAXLENGTH) return text
const cut = text.slice(0, MAXLENGTH)
const space = cut.lastIndexOf(' ')
return `${(space > MAXLENGTH / 2 ? cut.slice(0, space) : cut).trimEnd()}...`
}
// Called by a detail view once its record has loaded. An empty or missing name
// leaves the generic title in place rather than showing a bare id: "Machine"
// beats "Machine 4192" for somebody scanning a row of tabs, and a record that
// failed to load has no name to show anyway.
export function setRecordTitle(name) {
const text = (name === null || name === undefined) ? '' : String(name).trim()
if (!text) return
recordTitle = shorten(text)
document.title = compose(recordTitle)
}
// Exposed for tests.
export function _state() {
return { activeKey, recordTitle, sitePrefix }
}
export function _reset() {
activeKey = null
recordTitle = null
sitePrefix = 'ShopDB'
}

View File

@@ -0,0 +1,177 @@
// The tab title names the record, and the two writers do not fight.
//
// The router can only name the KIND of page - it runs before the view has
// fetched anything - so a detail view names the record itself once it loads.
// That leaves two writers for one string, and the router's is asynchronous
// (it awaits the site settings), so on a cold load it can finish LAST and
// overwrite a good title with the generic one. These pin that ordering.
import { describe, it, expect, beforeEach, vi } from 'vitest'
// The real getFacilityName caches, so only the first call is slow and every
// later one is instant. Mirror that: pending calls are held until the test
// releases them, and the value is served synchronously from then on. A mock
// that hands out a fresh unresolved promise per call hangs the moment a test
// navigates twice.
let pending = []
let settled = null
function resolveFacility(value) {
settled = value
const waiting = pending
pending = []
waiting.forEach((resolve) => resolve(value))
}
vi.mock('@/utils/siteSettings', () => ({
getFacilityName: () => (settled !== null
? Promise.resolve(settled)
: new Promise((resolve) => { pending.push(resolve) })),
}))
import {
beginNavigation, applyGenericTitle, setRecordTitle, _reset,
} from './pageTitle'
beforeEach(() => {
_reset()
document.title = ''
pending = []
settled = null
})
describe('the generic title', () => {
it('names the section when no record has been set', async () => {
beginNavigation('/machines')
const done = applyGenericTitle('/machines', 'Machines')
resolveFacility('West Jefferson')
await done
expect(document.title).toBe('West Jefferson ShopDB - Machines')
})
it('falls back to plain ShopDB when the facility is unset', async () => {
beginNavigation('/machines')
const done = applyGenericTitle('/machines', 'Machines')
resolveFacility('ShopDB')
await done
expect(document.title).toBe('ShopDB - Machines')
})
it('drops the site name entirely on the dashboard', async () => {
beginNavigation('/')
const done = applyGenericTitle('/', 'Home')
resolveFacility('West Jefferson')
await done
expect(document.title).toBe('West Jefferson ShopDB')
})
})
describe('a record title', () => {
it('names the record once the view has loaded it', async () => {
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('CSF16')
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
})
it('SURVIVES a slow router that resolves afterwards', async () => {
// The regression this file exists for. The view wins the race, the
// settings fetch lands second, and the generic write must stand down.
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
setRecordTitle('CSF16') // view got there first
resolveFacility('West Jefferson')
await done // router finishes last
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
})
it('is ignored when the record has no usable name', async () => {
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('')
setRecordTitle(null)
setRecordTitle(undefined)
setRecordTitle(' ')
// the section name is a better tab than a blank or a bare id
expect(document.title).toBe('West Jefferson ShopDB - Machines')
})
it('shortens a name too long for a tab, at a word boundary', async () => {
// Knowledge base articles are named with a sentence of description, and a
// browser truncates those mid-word wherever it happens to run out.
beginNavigation('/knowledgebase/2')
const done = applyGenericTitle('/knowledgebase/2', 'Knowledge Base')
resolveFacility('West Jefferson')
await done
const full = 'Documentation on how to image a Standard or Business PC '
+ 'in GCC High using MediaCreator Lite'
setRecordTitle(full)
expect(document.title.length).toBeLessThan(90)
expect(document.title.startsWith('West Jefferson ShopDB - ')).toBe(true)
expect(document.title.endsWith('...')).toBe(true)
// what survives is a whole-word prefix of the original: the character
// following the cut in the source is a space, so no word was split
const kept = document.title
.replace('West Jefferson ShopDB - ', '')
.replace(/\.\.\.$/, '')
expect(full.startsWith(kept)).toBe(true)
expect(full[kept.length]).toBe(' ')
})
it('leaves a name that already fits alone', async () => {
beginNavigation('/machines/14')
const done = applyGenericTitle('/machines/14', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('0600')
expect(document.title).toBe('West Jefferson ShopDB - 0600')
})
it('does not leak onto the next page', async () => {
beginNavigation('/machines/12')
const first = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await first
setRecordTitle('CSF16')
beginNavigation('/printers')
const second = applyGenericTitle('/printers', 'Printers')
await second
expect(document.title).toBe('West Jefferson ShopDB - Printers')
})
it('is discarded when the user navigates away mid-flight', async () => {
// Two navigations overlap; the first one's late generic write must not
// land on the second page.
beginNavigation('/machines/12')
const stale = applyGenericTitle('/machines/12', 'Machines')
beginNavigation('/printers/3')
const fresh = applyGenericTitle('/printers/3', 'Printers')
setRecordTitle('WJPRT04')
resolveFacility('West Jefferson')
await Promise.all([stale, fresh])
expect(document.title).toBe('West Jefferson ShopDB - WJPRT04')
})
})

View File

@@ -45,6 +45,7 @@ export const assetTypeCols = [
export const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'guide', label: 'Guides' },
{ key: 'employee', label: 'Employees' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },

View File

@@ -6,7 +6,7 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
import { beginNavigation, applyGenericTitle } from '../composables/pageTitle'
import { stagedChildren, stagedToplevel } from './routes.gen'
// Auto-discover all route modules from routes/ directory. A module's default
@@ -147,14 +147,12 @@ function pageTitleFor(route) {
word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
router.afterEach(async (to) => {
let siteTitle = 'ShopDB'
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB`
} catch (titleError) { /* settings unavailable - plain ShopDB */ }
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
router.afterEach((to) => {
// Claim the route BEFORE awaiting anything: a detail view that resolves
// quickly sets the record title, and the generic write below must not
// overwrite it. See composables/pageTitle.js.
beginNavigation(to.fullPath)
applyGenericTitle(to.fullPath, pageTitleFor(to))
})
// A deploy replaces the hashed chunk files, so a tab that was open across it

View File

@@ -98,22 +98,20 @@ function darkenHexColor(hex, fraction) {
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
// templates, and the global-search URL template (all with a {ticket}
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
// placeholder).
//
// The defaults are BLANK and the integration ships OFF, matching the server.
// They used to be one organisation's QA ServiceNow tenant, so any site that had
// not configured ServiceNow still rendered ticket links - pointing its
// technicians at a tenant they cannot reach and, for an adopting site, at
// somebody else's. A blank URL renders tickets as plain text, which is the
// correct behaviour for "not configured".
export async function getServicenowUrls() {
const enabledRaw = await getSetting('servicenow_enabled', 'true')
const enabledRaw = await getSetting('servicenow_enabled', 'false')
const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false
const incidentUrl = await getSetting(
'servicenow_incident_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const changeUrl = await getSetting(
'servicenow_change_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const searchUrl = await getSetting(
'servicenow_search_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const incidentUrl = await getSetting('servicenow_incident_url', '')
const changeUrl = await getSetting('servicenow_change_url', '')
const searchUrl = await getSetting('servicenow_search_url', '')
return { enabled, incidentUrl, changeUrl, searchUrl }
}

View File

@@ -35,6 +35,18 @@
<div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query">
<!--
A domain that could not be searched is stated plainly. Without this,
a broken table reads as "that record does not exist" - and the person
goes off believing the machine was never entered.
-->
<div v-if="degraded.length" class="search-degraded">
<strong>Some results may be missing.</strong>
Could not search: {{ degraded.join(', ') }}.
Anything in {{ degraded.length > 1 ? 'those areas' : 'that area' }}
will not appear below, even if it exists.
</div>
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
@@ -103,6 +115,7 @@ const router = useRouter()
const loading = ref(false)
const results = ref([])
const suggestion = ref(null)
const degraded = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -117,6 +130,7 @@ const typeLabels = {
computer: 'PC',
application: 'App',
knowledgebase: 'KB',
guide: 'Guide',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
@@ -136,6 +150,7 @@ const filterTypeMap = {
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
guides: ['guide'],
notifications: ['notification'],
employees: ['employee'],
usb: ['usb_device'],
@@ -151,6 +166,7 @@ const filterList = [
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'guides', label: 'Guides' },
{ key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' },
{ key: 'usb', label: 'USB' },
@@ -179,6 +195,7 @@ async function search(q) {
if (!q || q.length < 2) {
results.value = []
suggestion.value = null
degraded.value = []
return
}
@@ -192,6 +209,7 @@ async function search(q) {
// call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null
degraded.value = data?.degraded || []
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -424,6 +442,7 @@ watch(results, () => {
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
@@ -437,6 +456,7 @@ watch(results, () => {
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
@@ -507,4 +527,16 @@ watch(results, () => {
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
/* Warning, not an error: results below are still usable, just incomplete. */
.search-degraded {
margin-bottom: 1rem;
padding: 0.7rem 0.9rem;
border: 1px solid var(--warning, #b26a00);
border-left-width: 4px;
border-radius: 4px;
background: var(--surface, transparent);
color: var(--text);
font-size: 0.9rem;
}
</style>

View File

@@ -34,7 +34,7 @@
<input
type="url"
v-model="settings.servicenow_search_url"
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
placeholder="https://yourtenant.service-now.com/now/nav/ui/search/.../{ticket}/..."
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
:disabled="saving"
>

View File

@@ -35,12 +35,55 @@
</div>
</div>
</div>
<!--
Retention is a setting; purging is an ACT. Keeping them apart matters here
more than in most places: saving a number must never silently destroy
history, so the count of what would go is shown before anything happens
and the deletion is a separate, deliberate press.
-->
<div class="card">
<h3 class="group-title">Audit Log Size</h3>
<div v-if="audit.loading" class="muted">Loading...</div>
<div v-else-if="audit.error" class="error">{{ audit.error }}</div>
<template v-else>
<div class="audit-stats">
<span><strong>{{ audit.total.toLocaleString() }}</strong> entries</span>
<span v-if="audit.oldest" class="muted">oldest {{ shortDate(audit.oldest) }}</span>
<span v-if="audit.newest" class="muted">newest {{ shortDate(audit.newest) }}</span>
</div>
<p v-if="!audit.retentiondays" class="muted">
No retention period is set, so nothing is ever deleted. Set
<em>Audit Log Retention</em> above and save to enable purging.
</p>
<p v-else-if="audit.expired" class="muted">
<strong>{{ audit.expired.toLocaleString() }}</strong> entries are older
than {{ audit.retentiondays }} days and would be deleted.
</p>
<p v-else class="muted">
Nothing is older than {{ audit.retentiondays }} days.
</p>
<div class="actions">
<button class="btn btn-secondary" :disabled="audit.busy" @click="loadAudit">
Refresh
</button>
<button class="btn btn-danger"
:disabled="audit.busy || !audit.retentiondays || !audit.expired"
@click="purgeAudit">
{{ audit.busy ? 'Purging...' : 'Purge now' }}
</button>
<span v-if="audit.message" class="muted">{{ audit.message }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api, { settingsApi } from '@/api'
import { apiError } from '../../utils/apiError'
const items = ref([])
@@ -75,6 +118,7 @@ const LABELS = {
employee_directory_mode: 'Employee Directory Mode',
usb_directory_mode: 'USB Directory Mode',
site_timezone: 'Site Timezone',
audit_retentiondays: 'Audit Log Retention (days)',
setup_complete: 'Setup Complete',
asset_import_csv_location: 'Asset Import CSV Location'
}
@@ -105,6 +149,7 @@ const GROUPS = [
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
{ title: 'Localization', keys: ['site_timezone'] },
{ title: 'Audit Log', keys: ['audit_retentiondays'] },
{ title: 'System', keys: ['setup_complete'] }
]
@@ -117,12 +162,68 @@ const HELP = {
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
audit_retentiondays: 'Delete audit entries older than this many days. 0 keeps everything forever, which is the default - nothing is removed until you set a number here. Saving a retention period does NOT delete anything by itself; use Purge below, or run "flask audit purge --apply" on the server.',
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
}
function fieldHelp(key) {
return HELP[key] || ''
}
// ---------------------------------------------------------------- audit ---
const audit = ref({ loading: true, busy: false, error: '', message: '',
total: 0, oldest: null, newest: null,
retentiondays: 0, expired: 0 })
function shortDate (iso) {
const d = new Date(iso)
return isNaN(d) ? iso : d.toLocaleDateString()
}
async function loadAudit () {
audit.value.error = ''
try {
const response = await api.get('/auditlogs/retention')
const data = response.data?.data || response.data
Object.assign(audit.value, {
total: data.total || 0,
oldest: data.oldest,
newest: data.newest,
retentiondays: data.retentiondays || 0,
expired: data.expired || 0,
})
} catch (err) {
audit.value.error = apiError(err, 'Could not read the audit log size.')
} finally {
audit.value.loading = false
}
}
async function purgeAudit () {
const days = audit.value.retentiondays
const ok = window.confirm(
'Permanently delete ' + audit.value.expired.toLocaleString() +
' audit entries older than ' + days + ' days?\n\n' +
'Audit history cannot be recovered once deleted.')
if (!ok) return
audit.value.busy = true
audit.value.message = ''
try {
const response = await api.post('/auditlogs/purge', { days })
const result = response.data?.data || response.data
audit.value.message = response.data?.message || 'Purged.'
// A capped run leaves work behind, and says so rather than looking done.
if (result && result.complete === false) {
audit.value.message += ' Press Purge again to continue.'
}
await loadAudit()
} catch (err) {
audit.value.error = apiError(err, 'Could not purge the audit log.')
} finally {
audit.value.busy = false
}
}
async function load() {
loading.value = true
try {
@@ -156,7 +257,7 @@ async function save() {
}
}
onMounted(load)
onMounted(() => { load(); loadAudit() })
</script>
<style scoped>
@@ -218,4 +319,6 @@ onMounted(load)
gap: 12px;
margin-top: 8px;
}
.audit-stats { display: flex; gap: 1.25rem; align-items: baseline;
flex-wrap: wrap; margin-bottom: 0.5rem; }
</style>

View File

@@ -168,6 +168,7 @@ import { applicationsApi } from '@/api'
import { getContactEmailDomain } from '@/utils/siteSettings'
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
import { fileHref } from '@/utils/basePath'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
@@ -202,6 +203,7 @@ onMounted(async () => {
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
setRecordTitle(app.value?.appname)
// Load versions
try {

View File

@@ -27,8 +27,10 @@ depends_on = None
def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
inspector = sa.inspect(op.get_bind())
if 'backuprevisions' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
if 'lastseenat' not in columns:
op.add_column('backuprevisions',
sa.Column('lastseenat', sa.DateTime(), nullable=True))
@@ -37,7 +39,9 @@ def upgrade():
def downgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
inspector = sa.inspect(op.get_bind())
if 'backuprevisions' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.drop_column('backuprevisions', 'lastseenat')

View File

@@ -410,7 +410,13 @@ class BackupsPlugin(BasePlugin):
# with_for_update serializes concurrent posts for the same chain: the
# fleet collects on one GE-Enforce cycle, so a retry could otherwise
# pass the hash check twice and insert the same revision.
sourcehostname = payload.get('sourcehostname')
# Normalize ONCE, and use the same value to query and to store.
# samesource() maps '' to IS NULL, but the insert below used to store
# the raw '' - so a client posting an empty hostname wrote rows that its
# own next post could never match. Every post then deduped against
# nothing, inserting forever: unbounded growth, and a history no
# technician can read. Whitespace does the same thing more quietly.
sourcehostname = (payload.get('sourcehostname') or '').strip() or None
latest = (db.session.query(BackupRevision)
.filter(BackupRevision.assetid == assetid,
BackupRevision.backupkind == kindkey,
@@ -476,7 +482,7 @@ class BackupsPlugin(BasePlugin):
sharepath=sharepath,
sourcefilename=sourcefilename,
bytesize=bytesize,
sourcehostname=payload.get('sourcehostname'),
sourcehostname=sourcehostname,
collectedat=collectedat or datetime.utcnow(),
lastseenat=datetime.utcnow(),
)

View File

@@ -32,6 +32,31 @@ import re
SCHEMA = 'ntlars/1'
# SECRETS ARE NOT BACKED UP.
#
# The DNC registry carries an FTP password in cleartext. Stored verbatim it
# reached three places it must not: the backuprevisions table, the revision
# panel (which renders values), and the download route (which hands back a
# ready-to-import .reg). Anyone with backups.view could read the fleet's FTP
# password off a web page.
#
# Redaction happens at PARSE, so the secret never enters the database at all.
# Redacting only on display would leave it at rest and one query away.
#
# What is deliberately NOT done: dropping the value silently. This module's
# parser refuses to discard an unparseable value precisely because a backup that
# looks complete and restores a machine with a setting missing is the worst
# outcome. A redacted value keeps its NAME and records itself in
# `redacted`, so both the panel and a restore can say plainly that this one
# field must be set by hand.
REDACTEDMARKER = '***REDACTED***'
SECRETNAMEPATTERN = re.compile(r'passw|pwd|secret|apikey|token', re.IGNORECASE)
def issecretname(name):
"""Whether a registry value name holds a credential."""
return bool(SECRETNAMEPATTERN.search(name or ''))
REGROOT = 'HKEY_LOCAL_MACHINE'
DNCPATH = r'SOFTWARE\GE Aircraft Engines\DNC'
DNCPATHWOW = r'SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
@@ -245,6 +270,20 @@ def parse(raw):
dialect = 'wow6432node' if sawwow else ('ntlars' if sawplain else 'unknown')
# Strip credentials before the projection is returned - i.e. before anything
# persists it. Recorded by path\name so the omission is visible rather than
# discovered during a restore.
redacted = []
for path in keys:
for name, value in keys[path].items():
if not issecretname(name):
continue
if value.get('data') in ('', None):
continue # nothing to hide
keys[path][name] = {'type': value.get('type', 'REG_SZ'),
'data': REDACTEDMARKER}
redacted.append('{}\\{}'.format(path, name) if path else name)
return {
'schema': SCHEMA,
'sourcedialect': dialect,
@@ -252,6 +291,7 @@ def parse(raw):
{'path': path, 'values': dict(sorted(keys[path].items()))}
for path in sorted(keys)
],
'redacted': sorted(redacted),
}
@@ -291,6 +331,12 @@ def render(projection, dialect='ntlars', comments=None):
dialect='ntlars' omits WOW6432Node - use with the NTLARS Load... button
dialect='wow6432node' includes it - use with `reg import` on 64-bit
Redacted credentials are rendered as a comment naming the value, NOT as the
marker string. Importing '***REDACTED***' as the FTP password would leave a
machine that looks restored and cannot connect, and the technician would be
debugging the network. A commented-out value fails loudly at the one moment
somebody is looking at the file.
"""
if dialect not in ('ntlars', 'wow6432node'):
raise ValueError('unknown dialect: {!r}'.format(dialect))
@@ -302,10 +348,28 @@ def render(projection, dialect='ntlars', comments=None):
if comments:
out.append('')
redacted = projection.get('redacted') or []
if redacted:
out.append('; ---------------------------------------------------------------')
out.append('; {} credential value(s) were NOT backed up and are NOT in this'
.format(len(redacted)))
out.append('; file. Set them by hand after importing:')
for name in redacted:
out.append('; {}'.format(name))
out.append('; ---------------------------------------------------------------')
out.append('')
for key in projection.get('keys', []):
path = key.get('path', '')
out.append('[{}]'.format(root + ('\\' + path if path else '')))
for name, entry in (key.get('values') or {}).items():
# A redacted credential is commented out, never emitted as the
# marker. Importing the marker as a password produces a machine that
# looks restored and cannot log in.
if (entry or {}).get('data') == REDACTEDMARKER:
out.append('; "{}" was not backed up - set it by hand'.format(
_escape(name)))
continue
out.append(_rendervalue(name, entry))
out.append('')

View File

@@ -6,7 +6,7 @@ export default [
path: 'reports/pc-relationships',
name: 'report-pc-relationships',
component: () => import('./views/PCRelationshipsReport.vue'),
meta: { plugin: 'computers' }
meta: { plugin: 'computers', title: 'PC Relationships Report' }
},
{
path: 'settings/pctypemapping',

View File

@@ -5,6 +5,7 @@
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -276,7 +277,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
@@ -285,8 +286,13 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
@@ -302,6 +308,7 @@ onMounted(async () => {
try {
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
// Load installed applications
try {
@@ -330,6 +337,23 @@ function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await computersApi.delete(route.params.id)
toast.success(`Retired ${label}`)
router.push('/pcs')
} catch (error) {
console.error('Error retiring PC:', error)
toast.error(apiError(error, 'Failed to retire PC'))
}
}
</script>
<style scoped>

View File

@@ -153,6 +153,7 @@ import { withBase } from '@/utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
@@ -223,6 +224,7 @@ async function loadEmployee() {
try {
const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data
setRecordTitle(employee.value?.name || employee.value?.sso)
} catch (err) {
console.error('Error loading employee:', err)
error.value = 'Employee not found'

View File

@@ -0,0 +1,3 @@
from .plugin import GuidesPlugin
__all__ = ['GuidesPlugin']

View File

@@ -0,0 +1,3 @@
from .routes import guides_bp
__all__ = ['guides_bp']

View File

@@ -0,0 +1,573 @@
"""Guides API.
TWO WAYS TO READ A GUIDE, on purpose:
GET /api/guides/<slug> what was PUBLISHED
GET /api/guides/<id>/working the working copy, for editors
A reader following a procedure must not have it change under them because
somebody upstairs is editing. The published pointer is what makes that true, and
serving the working copy from the reader's route would quietly undo it.
"""
from datetime import datetime
import json
import re
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db,
success_response, error_response, ErrorCodes,
require_permission, harden_upload_response,
)
from ..models import Guide, GuideRevision, GuideImage
from ..services import authoring
from ..services.blocks import BlockError, checklistitemcount
guides_bp = Blueprint('guides', __name__)
# Screenshots of a settings dialog, not photographs. Generous for the former and
# small enough that a pasted phone picture is refused rather than silently
# making a guide take ten seconds to open on a shopfloor PC.
MAXIMAGEBYTES = 8 * 1024 * 1024
ALLOWEDIMAGETYPES = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
}
def _userid():
if current_user is not None:
return current_user.userid
try:
return int(get_jwt_identity())
except (TypeError, ValueError):
return None
def _has(permission):
"""Whether the caller holds a permission, without raising.
require_permission decorates a route; this is for the in-route decisions -
may this person see drafts, may they edit this particular guide. Same check
the decorator makes, including the admin bypass that lives inside
haspermission.
"""
return bool(current_user is not None
and current_user.haspermission(permission))
def _mayedit(guide):
"""Edit rights for one guide.
guides.edit covers everything. Otherwise an author may edit their OWN guide
while it is unpublished - which is what makes drafting useful without
handing everyone rights over published procedures. Once it is published it
takes guides.edit, because by then other people are following it.
"""
if _has('guides.edit'):
return True
if not _has('guides.create'):
return False
return guide.createdby == _userid() and not guide.publishedrevisionid
def _slugify(title, guideid=None):
"""A readable, stable, unique slug.
Stored rather than derived on read, so renaming a guide cannot break a link
somebody pasted into a work order months ago.
"""
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
base = base[:180] # room for a '-2' style suffix inside the 191 cap
candidate = base
suffix = 2
while True:
clash = Guide.query.filter(Guide.slug == candidate)
if guideid:
clash = clash.filter(Guide.guideid != guideid)
if not clash.first():
return candidate
candidate = '{}-{}'.format(base, suffix)
suffix += 1
# ---------------------------------------------------------------- reading ---
@guides_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_guides():
"""Published guides, plus the caller's own drafts if they can author.
A half-written procedure on a shop floor is worse than no procedure, so
drafts are invisible to readers - but an author must be able to find their
own, or drafting is a trap door.
"""
caneditall = _has('guides.edit')
mine = _userid()
# Match against the PUBLISHED text for anything the caller is only allowed
# to read, and against the working copy only for guides they may edit.
# Searching the working copy for everyone leaks draft wording to readers:
# they get a hit on a word that is not in the page when they open it.
published = db.aliased(GuideRevision)
query = Guide.query.outerjoin(
published, Guide.publishedrevisionid == published.guiderevisionid)
search = (request.args.get('q') or '').strip()
if search:
like = '%{}%'.format(search)
def matches(title, summary, tags, blocks):
# The body too: people look for "the one about the drive letter",
# not for a title they never read.
return db.or_(title.ilike(like), summary.ilike(like),
tags.ilike(like), blocks.ilike(like))
working = matches(Guide.title, Guide.summary, Guide.tags, Guide.blocksjson)
readable = matches(published.title, published.summary,
published.tags, published.blocksjson)
if caneditall:
query = query.filter(db.or_(working, readable))
else:
query = query.filter(db.or_(
readable,
db.and_(Guide.createdby == mine, working),
))
tag = (request.args.get('tag') or '').strip()
if tag:
like = '%{}%'.format(tag)
if caneditall:
query = query.filter(db.or_(Guide.tags.ilike(like),
published.tags.ilike(like)))
else:
query = query.filter(db.or_(
published.tags.ilike(like),
db.and_(Guide.createdby == mine, Guide.tags.ilike(like)),
))
if not caneditall:
query = query.filter(db.or_(
Guide.publishedrevisionid.isnot(None),
Guide.createdby == mine,
))
rows = query.order_by(Guide.modifieddate.desc()).all()
out = []
for guide in rows:
data = guide.to_dict()
data['ispublished'] = bool(guide.publishedrevisionid)
data['checklistitems'] = checklistitemcount(guide.blocks)
data['canedit'] = _mayedit(guide)
# A reader's card must describe the guide they will actually open. The
# Guide row carries the working copy, so a retitled draft would list
# under its new name and open under its old one.
if not data['canedit'] and guide.publishedrevisionid:
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
if revision is not None:
data['title'] = revision.title
data['summary'] = revision.summary
data['tags'] = [t.strip() for t in (revision.tags or '').split(',')
if t.strip()]
data['checklistitems'] = checklistitemcount(revision.blocks)
out.append(data)
return success_response(out)
@guides_bp.route('/tags', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_tags():
"""Every tag in use, with a count, for the filter chips.
Readers count published tags only. A chip carrying a tag that exists solely
on somebody's draft is both a leak and a dead end - filtering by it returns
nothing a reader is allowed to see.
"""
counts = {}
if _has('guides.edit'):
pairs = db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all()
else:
published = db.aliased(GuideRevision)
pairs = db.session.query(published.tags).join(
Guide, Guide.publishedrevisionid == published.guiderevisionid
).filter(published.tags.isnot(None)).all()
mine = _userid()
pairs += db.session.query(Guide.tags).filter(
Guide.createdby == mine, Guide.tags.isnot(None)).all()
for (tags,) in pairs:
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
counts[tag] = counts.get(tag, 0) + 1
return success_response([
{'tag': tag, 'count': count}
for tag, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0].lower()))
])
@guides_bp.route('/<slug>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_guide(slug):
"""What a reader gets: the published revision.
An unpublished guide is 404 to anyone who cannot edit it - not 403, which
would confirm it exists.
"""
guide = Guide.query.filter_by(slug=slug).first()
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
revision = authoring.readable(guide)
if revision is None:
if not _mayedit(guide):
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found',
http_code=404)
# An editor previewing their own unpublished draft.
data = guide.to_dict(includeblocks=True)
data['ispublished'] = False
data['isdraftpreview'] = True
data['canedit'] = True
return success_response(data)
data = guide.to_dict()
data.update({
'title': revision.title,
'summary': revision.summary,
'tags': [t.strip() for t in (revision.tags or '').split(',') if t.strip()],
'blocks': revision.blocks,
'revision': revision.revision,
'ispublished': True,
'isdraftpreview': False,
'canedit': _mayedit(guide),
# So an editor can see there is newer unpublished work without leaving
# the page.
'hasunpublishedchanges': guide.revision != revision.revision,
})
return success_response(data)
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_working(guideid):
"""The working copy, for the editor."""
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to edit this guide',
http_code=403)
data = guide.to_dict(includeblocks=True)
data['ispublished'] = bool(guide.publishedrevisionid)
data['canedit'] = True
return success_response(data)
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_revisions(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
# History is editors' business. guides.view alone is permission to read what
# was PUBLISHED; the revision list is every draft this guide has ever been,
# including the ones deliberately not published yet.
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to see this history',
http_code=403)
rows = (GuideRevision.query.filter_by(guideid=guideid)
.order_by(GuideRevision.revision.desc()).all())
published = guide.publishedrevisionid
return success_response([
dict(row.to_dict(), ispublished=(row.guiderevisionid == published))
for row in rows
])
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_revision(revisionid):
"""One revision by id.
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
integers, so an ungated route here is an invitation to walk 1..n and read
every unpublished draft on the site - which is the opposite of what
the GUIDES wiki page promises about drafts.
A reader may fetch the published revision (it is what the slug route would
serve anyway); everything else takes edit rights on that guide.
"""
revision = db.session.get(GuideRevision, revisionid)
if revision is None:
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
guide = db.session.get(Guide, revision.guideid)
ispublished = bool(guide is not None
and guide.publishedrevisionid == revision.guiderevisionid)
if not ispublished and not (guide is not None and _mayedit(guide)):
# 404, not 403: a draft's existence is itself the thing being withheld.
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
return success_response(revision.to_dict(includeblocks=True))
# ---------------------------------------------------------------- writing ---
@guides_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('guides.create')
def create_guide():
data = request.get_json(silent=True) or {}
title = (data.get('title') or '').strip()
if not title:
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
guide = Guide(title=title, slug=_slugify(title), status='draft',
createdby=_userid(), updatedby=_userid())
db.session.add(guide)
db.session.flush()
try:
authoring.save(guide,
title=title,
summary=data.get('summary', ''),
tags=data.get('tags', ''),
blocks=data.get('blocks', []),
note=data.get('note'),
userid=_userid())
except BlockError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
db.session.commit()
return success_response(guide.to_dict(includeblocks=True),
message='Guide created')
@guides_bp.route('/<int:guideid>', methods=['PUT'])
@jwt_required()
@require_permission('guides.view')
def update_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to edit this guide',
http_code=403)
data = request.get_json(silent=True) or {}
if 'title' in data and not (data.get('title') or '').strip():
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
try:
guide, revision = authoring.save(
guide,
title=data.get('title'),
summary=data.get('summary'),
tags=data.get('tags'),
blocks=data.get('blocks'),
note=data.get('note'),
userid=_userid())
except BlockError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
# The slug is set once, at create, and does NOT follow the title.
#
# _slugify's own docstring says the slug is stored so that renaming cannot
# break a link somebody pasted into a work order months ago - and then this
# re-slugged on every save that carried a title, which the editor's 3-second
# autosave does on every keystroke. Fixing a typo in a heading silently
# moved the guide's URL and broke exactly the links the stored slug exists
# to protect. A guide with no slug (older row, or a failed create) still
# gets one here.
if not guide.slug:
guide.slug = _slugify(guide.title, guide.guideid)
db.session.commit()
result = guide.to_dict(includeblocks=True)
# 'unchanged' is a real outcome, not a failure: an autosave where the author
# changed nothing must not manufacture a version.
result['saved'] = revision is not None
return success_response(result,
message='Saved' if revision else 'No changes to save')
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
@jwt_required()
@require_permission('guides.publish')
def publish_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
try:
revision = authoring.publish(guide, userid=_userid())
except ValueError as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
db.session.commit()
return success_response(guide.to_dict(),
message='Published version {}'.format(revision.revision))
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
@jwt_required()
@require_permission('guides.publish')
def unpublish_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
authoring.unpublish(guide, userid=_userid())
db.session.commit()
return success_response(guide.to_dict(), message='Withdrawn from readers')
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
@jwt_required()
@require_permission('guides.view')
def restore_revision(guideid, revisionid):
guide = db.session.get(Guide, guideid)
revision = db.session.get(GuideRevision, revisionid)
if guide is None or revision is None or revision.guideid != guideid:
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
http_code=404)
if not _mayedit(guide):
return error_response(ErrorCodes.FORBIDDEN,
'You do not have permission to edit this guide',
http_code=403)
authoring.restore(guide, revision, userid=_userid())
db.session.commit()
return success_response(guide.to_dict(includeblocks=True),
message='Restored version {} as a new draft version'
.format(revision.revision))
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
@jwt_required()
@require_permission('guides.delete')
def delete_guide(guideid):
guide = db.session.get(Guide, guideid)
if guide is None:
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
db.session.delete(guide)
db.session.commit()
return success_response({'guideid': guideid}, message='Guide deleted')
# ----------------------------------------------------------------- images ---
@guides_bp.route('/images', methods=['POST'])
@jwt_required()
@require_permission('guides.create')
def upload_image():
"""Take a pasted or dropped image.
Unattached at first: an image is pasted into a NEW guide before that guide
has an id. The editor attaches it on save; anything never attached is
orphaned and prunable, which is a tidier problem than refusing to accept a
paste until the author has saved.
"""
upload = request.files.get('file')
if upload is None:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
mimetype = (upload.mimetype or '').lower()
if mimetype not in ALLOWEDIMAGETYPES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Unsupported image type {!r}. Allowed: {}'.format(
mimetype, ', '.join(sorted(ALLOWEDIMAGETYPES))))
data = upload.read()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'That file is empty')
if len(data) > MAXIMAGEBYTES:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That image is {:.1f} MB; the limit is {} MB'.format(
len(data) / 1048576, MAXIMAGEBYTES // 1048576))
guideid = request.form.get('guideid', type=int)
image = GuideImage(guideid=guideid, filename=upload.filename,
mimetype=mimetype, bytesize=len(data), data=data,
uploadedby=_userid())
db.session.add(image)
db.session.commit()
return success_response(image.to_dict(), message='Image uploaded')
def _mayseeimage(image):
"""Whether the caller may fetch this image.
An image is content. One pasted into a published guide must be readable by
anyone who can read that guide; one pasted into a draft must not be, or the
draft leaks a screenshot at a time to anyone willing to count upwards
through image ids.
So: editors of the parent see it, and everyone else sees it only if the
PUBLISHED revision actually references it. An unattached image - pasted
before the guide was first saved - belongs to whoever uploaded it.
"""
if image.guideid is None:
return _has('guides.edit') or image.uploadedby == _userid()
guide = db.session.get(Guide, image.guideid)
if guide is None:
return _has('guides.edit')
if _mayedit(guide):
return True
if not guide.publishedrevisionid:
return False
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
if revision is None:
return False
try:
blocks = json.loads(revision.blocksjson or '[]')
except (TypeError, ValueError):
return False
needle = '/guides/images/{}'.format(image.guideimageid)
for block in blocks:
if not isinstance(block, dict) or block.get('type') != 'image':
continue
if str(block.get('imageid') or '') == str(image.guideimageid):
return True
if needle in str(block.get('url') or ''):
return True
return False
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def serve_image(imageid):
image = db.session.get(GuideImage, imageid)
if image is None:
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
if not _mayseeimage(image):
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
# Same inert-document headers every other upload route serves under: an
# uploaded file is attacker-influenced content and must not be able to run
# as a document in this origin.
return harden_upload_response(Response(image.data, mimetype=image.mimetype, headers={
# Immutable: an image is never edited in place, only replaced by a new
# upload with a new id, so it can be cached hard.
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Disposition': 'inline; filename="{}"'.format(
image.filename or 'image{}'.format(imageid)),
}))

View File

@@ -0,0 +1,286 @@
<template>
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
<template v-for="(block, index) in blocks" :key="block.id || index">
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
v-html="inline(block.text)"></h2>
<p v-else-if="block.type === 'paragraph'" class="gb-para"
v-html="inline(block.text)"></p>
<div v-else-if="block.type === 'step'" class="gb-step">
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
<div v-if="block.text" v-html="inline(block.text)"></div>
</div>
<!--
The reason this feature exists. Interactive ONLY when viewing: in the
editor a tick would be an edit, and an author clicking around their own
draft would quietly change what the next reader sees as "done".
-->
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
<label>
<input type="checkbox"
:checked="isTicked(item.id)"
:disabled="!interactive"
@change="toggle(item.id, $event.target.checked)">
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
v-html="inline(item.text)"></span>
</label>
</li>
</ul>
<ul v-else-if="block.type === 'bullets'" class="gb-list">
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
</ul>
<ol v-else-if="block.type === 'numbers'" class="gb-list">
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
</ol>
<div v-else-if="block.type === 'callout'"
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
<strong v-if="block.title">{{ block.title }}</strong>
<div v-html="inline(block.text)"></div>
</div>
<div v-else-if="block.type === 'code'" class="gb-code">
<div class="gb-codehead">
<span class="gb-lang">{{ block.language || 'text' }}</span>
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
<!--
SELECTS the code; it does not write to the clipboard.
A page that programmatically copies a shell command is the exact
shape of a ClickFix attack - malware that plants a command in your
clipboard and talks you into pasting it into Run. uBlock Origin
blocks it, and it was right to: our button wrote PowerShell to the
clipboard on click.
Selecting the text and letting the person press Ctrl+C keeps the
convenience, moves the decision back to them, and gives security
tooling nothing to object to. It also selects the code ONLY - no
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
terminal.
-->
<button type="button" class="gb-copy" @click="select(block)">
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
</button>
</div>
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
</div>
<!--
The image is fetched by script, not by the browser.
An <img src> is a plain browser request: it carries no Authorization
header, and this app keeps its JWT in one. So a direct src against the
authenticated image route is a 401 and a broken-image icon for every
reader - which is exactly how this shipped. Fetching through the api
client sends the token, and it resolves against the client's baseURL, so
it also survives a subpath mount that a stored '/api/...' string does
not.
A failure says so in words. A guide whose screenshot is missing must not
look like a guide that never had one.
-->
<figure v-else-if="block.type === 'image'" class="gb-figure">
<img v-if="imageState(block).src" :src="imageState(block).src"
:alt="block.alt || ''">
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
</p>
<p v-else class="gb-imageloading">Loading image...</p>
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
</figure>
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
<table class="gb-table">
<thead v-if="block.header && block.header.length">
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
</thead>
<tbody>
<tr v-for="(row, r) in (block.rows || [])" :key="r">
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
</tr>
</tbody>
</table>
</div>
<!-- An unknown type renders as nothing rather than as an error. A guide
written against a newer version must stay readable on an older one. -->
</template>
</div>
</template>
<script setup>
import { ref, computed, toRef } from 'vue'
import { renderInline } from '../utils/inline.js'
import { useGuideImages } from '../utils/images.js'
const props = defineProps({
blocks: { type: Array, default: () => [] },
// Live checkboxes belong to the viewer only.
interactive: { type: Boolean, default: false },
ticked: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['toggle'])
const selected = ref('')
const codeNodes = {}
function registerCode (id, el) { if (el) codeNodes[id] = el }
// Images are fetched by script, not by <img src>; see utils/images.js.
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
function inline (text) {
return renderInline(text)
}
// Items may be plain strings (bullets) or objects with ids (checklists). One
// shape here so the template does not branch on it.
function itemsOf (block) {
return (block.items || []).map((item, index) =>
typeof item === 'string'
? { id: (block.id || 'b') + '-' + index, text: item }
: item)
}
function isTicked (itemid) {
return Boolean(props.ticked[itemid])
}
function toggle (itemid, checked) {
if (!props.interactive) return
emit('toggle', itemid, checked)
}
// Steps number themselves by position among steps, so inserting a paragraph
// between two of them does not renumber anything by hand.
const stepIndex = computed(() => {
const map = {}
let n = 0
props.blocks.forEach((block, index) => {
if (block.type === 'step') { n += 1; map[index] = n }
})
return map
})
function stepNumber (index) {
return stepIndex.value[index] || 1
}
function anchorFor (block) {
return 'g-' + (block.id || '')
}
function select (block) {
const node = codeNodes[block.id]
if (!node || !window.getSelection) return
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
selected.value = block.id
setTimeout(() => { selected.value = '' }, 4000)
}
</script>
<style scoped>
/* Every colour is a theme token. Nothing here is a literal, which is what makes
dark mode work with no dark-mode code. */
.gb-blocks { line-height: 1.65; }
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
.gb-step {
border-left: 3px solid var(--primary);
padding: 0.6rem 0 0.6rem 1rem;
margin: 0 0 1rem;
}
.gb-stepnum {
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
}
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
.gb-check { margin: 0.35rem 0; }
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
.gb-interactive .gb-check label:hover { color: var(--primary); }
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
.gb-done { opacity: 0.55; text-decoration: line-through; }
.gb-list { margin: 0 0 1rem 1.2rem; }
.gb-list li { margin: 0.25rem 0; }
.gb-callout {
border-left: 4px solid var(--info, var(--primary));
background: var(--surface-2, rgba(127, 127, 127, 0.08));
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
}
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
.gb-warning { border-left-color: var(--warning, #d9822b); }
.gb-success { border-left-color: var(--success, #2e9e5b); }
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
.gb-codehead {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
border-bottom: 1px solid var(--border); font-size: 0.75rem;
}
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
.gb-caption { color: var(--text-light); }
.gb-copy {
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer;
}
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
.gb-code pre {
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
/* WRAP rather than clip: these guides are full of long UNC paths, and a
line that scrolls out of sight is a command somebody copies half of. */
white-space: pre-wrap; word-break: break-word;
}
.gb-figure { margin: 0 0 1.2rem; }
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
.gb-imageloading, .gb-imagefailed {
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
border-radius: 4px; font-size: 0.9rem;
}
.gb-imageloading { color: var(--textmuted); }
.gb-imagefailed { color: var(--danger, #b3261e); }
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
.gb-table { border-collapse: collapse; width: 100%; }
.gb-table th, .gb-table td {
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
vertical-align: top; font-size: 0.9rem;
}
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
@media print {
/* Paper has no hover, no clipboard and no theme. The light-mode token
overrides live in GuideView.vue, which can reach :root; this block is only
about what belongs on paper. */
.gb-copy { display: none; }
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
a printed guide is a worksheet somebody carries to the bay and ticks by
hand, and one person's progress is not part of the procedure. */
.gb-done { opacity: 1; text-decoration: none; }
.gb-check input { appearance: none; -webkit-appearance: none;
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
border-radius: 2px; background: #fff; }
.gb-code pre { white-space: pre-wrap; }
.gb-stepnum { color: #000; }
}
</style>

View File

@@ -0,0 +1,42 @@
/**
* Guides plugin routes.
*
* Three views, and the split between the last two is deliberate: reading a
* guide and editing one are different jobs with different rights, and the
* checkboxes are live in exactly one of them.
*
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
* plugin is disabled. Every route requires auth because every guides API route
* is behind JWT plus a guides.* permission.
*/
export default [
{
path: 'guides',
name: 'guides',
component: () => import('./views/GuidesList.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
},
{
path: 'guides/new',
name: 'guide-new',
component: () => import('./views/GuideEditor.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
},
{
path: 'guides/:slug',
name: 'guide-view',
component: () => import('./views/GuideView.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
},
{
path: 'guides/:guideid/edit',
name: 'guide-edit',
component: () => import('./views/GuideEditor.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
}
]
// No full-screen routes: every view renders inside AppLayout. Declared so
// routes.gen.js can read `.toplevel` off the module without Rollup warning
// about a missing export.
export const toplevel = []

View File

@@ -0,0 +1,77 @@
/**
* Resolving guide images.
*
* A guide image lives behind an authenticated route, and an <img src> is a
* plain browser request that carries no Authorization header - this app keeps
* its JWT in one. Pointing src at the image route therefore yields 401 and a
* broken-image icon for every reader, which is how guides first shipped.
*
* So the bytes are fetched through the api client, which attaches the token and
* resolves against its own baseURL - the latter also fixing a stored
* '/api/guides/images/N' string under a subpath mount, where the API is not at
* /api at all.
*
* Used by the renderer AND the editor: an author needs to see the screenshot
* they just pasted as much as a reader does.
*/
import { reactive, watch, onBeforeUnmount } from 'vue'
import api from '@/api'
/** The image id a block refers to, whichever way it was written.
*
* Blocks store imageid (written since this was fixed) or a url string (written
* before it). The url is never used as a src, only read for its id.
*/
export function imageIdOf (block) {
if (!block) return ''
if (block.imageid) return String(block.imageid)
const match = /\/guides\/images\/(\d+)/.exec(block.url || '')
return match ? match[1] : ''
}
/**
* Keeps object URLs for the image blocks in `blocksRef`, fetching each id once
* however many blocks use it, and revoking them as blocks go away.
*
* Returns `state(block)` -> { src, failed }.
*/
export function useGuideImages (blocksRef) {
const images = reactive({})
async function load (id) {
if (images[id]) return
images[id] = { src: '', failed: false }
try {
const response = await api.get(`/guides/images/${id}`, { responseType: 'blob' })
images[id].src = URL.createObjectURL(response.data)
} catch (err) {
images[id].failed = true
}
}
function release (id) {
if (images[id] && images[id].src) URL.revokeObjectURL(images[id].src)
delete images[id]
}
function state (block) {
const id = imageIdOf(block)
if (!id) return { src: '', failed: true }
return images[id] || { src: '', failed: false }
}
// Editing replaces the block list on every keystroke, so resolve only ids
// that are new and keep the ones already held. Without that, every keystroke
// would refetch every image in the guide.
watch(blocksRef, blocks => {
const wanted = new Set(
(blocks || []).filter(b => b && b.type === 'image')
.map(imageIdOf).filter(Boolean))
wanted.forEach(load)
Object.keys(images).forEach(id => { if (!wanted.has(id)) release(id) })
}, { immediate: true, deep: true })
onBeforeUnmount(() => { Object.keys(images).forEach(release) })
return { state }
}

View File

@@ -0,0 +1,106 @@
/**
* Inline text rendering for guide blocks.
*
* A guide stores its text as a RESTRICTED Markdown subset - bold, italic,
* inline code, links, line breaks - and nothing else. This turns that into
* HTML.
*
* ESCAPE FIRST, THEN FORMAT. The order is the whole security model: every
* angle bracket and ampersand becomes an entity before any pattern is applied,
* so no markup an author pasted can survive into the DOM. There is no path here
* that emits a tag the code below did not write itself.
*
* The restriction is not only about safety. Pasted rich text carries fonts and
* hardcoded colours, which then fight the theme - black text on a dark
* background, and nobody can say why. Emphasis is semantic; the stylesheet
* decides how it looks. That is what makes dark mode work without an author
* thinking about it, and why a guide written next year still matches one
* written today.
*/
function escapeHtml (text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
// carrying entities: a real query string like ?a=1&b=2 reaches here as
// ?a=1&amp;b=2. Escaping that again for the attribute produced &amp;amp; and a
// link that opened the wrong record - the ServiceNow and SharePoint links that
// carry several parameters were all broken this way. Decode first, then let the
// single escape on output put back exactly one level.
function decodeEntities (text) {
return String(text)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&') // last, so &amp;lt; does not become <
}
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
// an author could otherwise smuggle script past the escaping above.
function safeHref (url) {
const trimmed = String(url || '').trim()
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
// In-app link. NOT '//host', which looks like a path and is read by the
// browser as protocol-relative - an off-site link wearing a local costume.
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
return null
}
export function renderInline (text) {
let html = escapeHtml(text)
// Inline code first, so formatting characters inside `code` stay literal -
// a path like C:\**temp** should not turn half of itself bold.
const codeSpans = []
html = html.replace(/`([^`]+)`/g, (match, code) => {
codeSpans.push(code)
return '\u0000CODE' + (codeSpans.length - 1) + '\u0000'
})
// [label](url). The URL pattern allows ONE level of balanced parentheses so
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
// and a naive [^)]+ silently truncates it into a broken link.
// Finished links are stashed like code spans, so the emphasis passes below
// cannot reach inside an href. Without that, a URL containing *characters*
// has <em> injected into the middle of the attribute and the link breaks.
const links = []
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
const href = safeHref(decodeEntities(url))
if (!href) return label
const external = /^https?:/i.test(href)
links.push('<a href="' + escapeHtml(href) + '"' +
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
'>' + label + '</a>')
return '\u0000LINK' + (links.length - 1) + '\u0000'
})
// **bold** then *italic*, longest marker first so **x** is not read as
// *(*x*)*.
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
html = html.replace(/\n/g, '<br>')
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
'<code>' + codeSpans[Number(index)] + '</code>')
return html
}
/** Plain text, for titles and anywhere markup would be noise. */
export function stripInline (text) {
return String(text == null ? '' : text)
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
}

View File

@@ -0,0 +1,50 @@
/**
* Inline rendering: the security model and the link bugs it hid.
*
* Escape-then-format is the whole defence, and it has one awkward consequence
* that produced real broken links: by the time a URL is captured it is already
* escaped, so it has to be decoded before it can be escaped again exactly once.
*/
import { describe, it, expect } from 'vitest'
import { renderInline } from './inline.js'
describe('renderInline', () => {
it('escapes markup an author pasted', () => {
expect(renderInline('<img src=x onerror=alert(1)>'))
.toBe('&lt;img src=x onerror=alert(1)&gt;')
})
it('keeps a multi-parameter URL intact', () => {
// Double-escaped, this became &amp;amp; and the link opened the wrong
// record. Every ServiceNow and SharePoint link carries several parameters.
const html = renderInline('[ticket](https://x.example/nav?a=1&b=2&c=3)')
expect(html).toContain('href="https://x.example/nav?a=1&amp;b=2&amp;c=3"')
expect(html).not.toContain('&amp;amp;')
})
it('does not let emphasis reach inside an href', () => {
const html = renderInline('[t](https://x.example/a*b*c)')
expect(html).toContain('href="https://x.example/a*b*c"')
expect(html).not.toContain('<em>')
})
it('refuses a protocol-relative URL dressed as an in-app path', () => {
// '//evil.example' starts with '/' but the browser reads it as off-site.
expect(renderInline('[evil](//evil.example/x)')).toBe('evil')
})
it('refuses javascript: and keeps the label', () => {
expect(renderInline('[bad](javascript:alert(1))')).toBe('bad')
})
it('allows an in-app link', () => {
expect(renderInline('[map](/map)')).toBe('<a href="/map">map</a>')
})
it('still renders bold, italic and code', () => {
expect(renderInline('**b**')).toBe('<strong>b</strong>')
expect(renderInline('*i*')).toBe('<em>i</em>')
expect(renderInline('`C:\\**temp**`'))
.toBe('<code>C:\\**temp**</code>')
})
})

View File

@@ -0,0 +1,145 @@
/**
* Proofing: the mistakes that matter in a PROCEDURE, not a document.
*
* A spellchecker catches "teh". None of these are spelling, and every one has
* been seen for real:
*
* - a smart quote in a command, so the command does not run
* - a checklist item with no text, so a step is silently missing
* - a heading with nothing under it, left when a draft was reorganised
* - an image with no alt text, invisible to anyone who cannot see it
* - a TODO that was never finished, published anyway
*
* Warnings, not blocks. A person publishing a guide at 14:40 with one empty
* bullet should be told, not stopped - the tool does not know that the bullet is
* deliberate, and a procedure that exists imperfectly beats one nobody wrote.
*/
// Characters an editor or a word processor substitutes silently. In prose they
// are harmless; in a command they are the difference between working and a
// syntax error nobody can explain.
const SMARTCHARS = [
// Written as escapes, not literals: the repo's style gate forbids non-ASCII in
// source, and a file whose job is detecting smart quotes cannot contain any.
// The escapes are also unambiguous in a diff, where a curly quote and a
// straight one look identical.
['\u2018', "'"], ['\u2019', "'"], // single quotes
['\u201C', '"'], ['\u201D', '"'], // double quotes
['\u2013', '-'], ['\u2014', '-'], // en dash, em dash
['\u00A0', ' '], // non-breaking space
]
const UNFINISHED = /\b(TODO|TBD|FIXME|XXX|\?\?\?)\b/i
function issue (level, blockid, message, fix) {
return { level, blockid, message, fix: fix || null }
}
export function proof (guide, blocks) {
const issues = []
if (!guide.title || !guide.title.trim()) {
issues.push(issue('error', null, 'This guide has no title'))
}
if (!guide.summary || !guide.summary.trim()) {
issues.push(issue('warn', null,
'No summary. It is what people read in the list and in search results.'))
}
if (!guide.tags || !guide.tags.trim()) {
issues.push(issue('warn', null, 'No tags, so this will be hard to find'))
}
if (!blocks.length) {
issues.push(issue('error', null, 'This guide is empty'))
return issues
}
blocks.forEach((block, index) => {
const where = 'block ' + (index + 1) + ' (' + block.type + ')'
if (block.type === 'code') {
// The important one. An autocorrected quote in a command looks right and
// does not run.
//
// Reported ONCE per block, however many offending characters it has: a
// command with an opening and a closing smart quote is one problem with
// one fix, and listing it twice makes the panel look like it is nagging.
const found = SMARTCHARS.filter(([bad]) => (block.text || '').includes(bad))
if (found.length) {
issues.push(issue('error', block.id,
where + ': contains ' + found.map(([, good]) => 'a curly or long ' +
(good === "'" ? 'quote' : good === '"' ? 'double quote' :
good === '-' ? 'dash' : 'space')).filter(
(v, i, a) => a.indexOf(v) === i).join(' and ') +
' where a command needs the plain character. Autocorrect does this, ' +
'and the command will not run.',
{ type: 'replaceAll', pairs: found }))
}
if (!(block.text || '').trim()) {
issues.push(issue('error', block.id, where + ' is empty'))
}
} else if (['heading', 'paragraph', 'callout'].includes(block.type)) {
if (!(block.text || '').trim() && !(block.title || '').trim()) {
issues.push(issue('warn', block.id, where + ' is empty'))
}
}
if (block.type === 'step' && !(block.title || '').trim()
&& !(block.text || '').trim()) {
issues.push(issue('warn', block.id, where + ' has no title and no text'))
}
if (['checklist', 'bullets', 'numbers'].includes(block.type)) {
const empties = (block.items || []).filter(
item => !String(typeof item === 'string' ? item : item.text || '').trim())
if (empties.length) {
issues.push(issue('warn', block.id,
where + ': ' + empties.length + ' empty item' +
(empties.length > 1 ? 's' : '') +
'. An empty step reads as a step somebody forgot to write.'))
}
}
if (block.type === 'image' && !(block.alt || '').trim()) {
issues.push(issue('warn', block.id,
where + ': no alt text. Anyone who cannot see the image gets nothing.'))
}
if (block.type === 'table' && !(block.rows || []).length) {
issues.push(issue('warn', block.id, where + ' has a header but no rows'))
}
const text = [block.text, block.title].filter(Boolean).join(' ')
if (UNFINISHED.test(text)) {
issues.push(issue('warn', block.id,
where + ': still contains ' + text.match(UNFINISHED)[0]))
}
})
// A heading immediately followed by another heading usually means the section
// under the first one was moved or never written.
blocks.forEach((block, index) => {
if (block.type !== 'heading') return
const next = blocks[index + 1]
if (!next || next.type === 'heading') {
issues.push(issue('warn', block.id,
'Heading "' + (block.text || '').slice(0, 40) + '" has nothing under it'))
}
})
return issues
}
/** Apply a suggested fix in place, for the ones where the answer is obvious. */
export function applyFix (block, fix) {
if (!fix) return false
const before = block.text || ''
let after = before
if (fix.type === 'replaceAll') {
for (const [bad, good] of fix.pairs) after = after.split(bad).join(good)
} else if (fix.type === 'replace') {
after = after.split(fix.from).join(fix.to)
}
if (after === before) return false
block.text = after
return true
}

View File

@@ -0,0 +1,74 @@
/**
* Starting points for a new guide.
*
* The blank page is the real barrier to somebody writing a procedure down. A
* skeleton with the right SHAPE - what you need first, the steps, how you know
* it worked, what to do when it does not - gets a half-decent guide out of
* somebody who would otherwise have written nothing.
*
* The shape is lifted from the KB articles the team already writes, so guides
* written here read like the ones written before them.
*/
function id (prefix, n) {
return prefix + n
}
export const templates = [
{
key: 'blank',
name: 'Blank',
description: 'Start with nothing',
blocks: () => [],
},
{
key: 'procedure',
name: 'Procedure',
description: 'Before you start, numbered steps, how to check it worked',
blocks: () => [
{ id: id('t', 1), type: 'paragraph',
text: 'What this covers, and when somebody would need it.' },
{ id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start',
text: 'Anything that has to be true first, or that will bite halfway through.' },
{ id: id('t', 3), type: 'heading', text: 'Steps' },
{ id: id('t', 4), type: 'step', title: 'First step', text: '' },
{ id: id('t', 5), type: 'step', title: 'Second step', text: '' },
{ id: id('t', 6), type: 'heading', text: 'Check it worked' },
{ id: id('t', 7), type: 'checklist', items: [
{ id: id('ti', 1), text: '' },
{ id: id('ti', 2), text: '' },
] },
],
},
{
key: 'troubleshooting',
name: 'Troubleshooting',
description: 'Symptom, likely causes, what to check first',
blocks: () => [
{ id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' },
{ id: id('t', 2), type: 'heading', text: 'Check these first' },
{ id: id('t', 3), type: 'checklist', items: [
{ id: id('ti', 1), text: '' },
{ id: id('ti', 2), text: '' },
] },
{ id: id('t', 4), type: 'heading', text: 'Likely causes' },
{ id: id('t', 5), type: 'table',
header: ['What you see', 'What it usually means'], rows: [['', '']] },
{ id: id('t', 6), type: 'callout', variant: 'info',
text: 'The thing that looks like the cause and is not.' },
],
},
{
key: 'checklist',
name: 'Checklist',
description: 'A list to work through, nothing else',
blocks: () => [
{ id: id('t', 1), type: 'paragraph', text: 'When to use this list.' },
{ id: id('t', 2), type: 'checklist', items: [
{ id: id('ti', 1), text: '' },
{ id: id('ti', 2), text: '' },
{ id: id('ti', 3), text: '' },
] },
],
},
]

View File

@@ -0,0 +1,717 @@
<template>
<div class="ge" @paste="onPaste" @dragover.prevent @drop.prevent="onDrop">
<div class="page-header">
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
<div class="ge-actions">
<span class="ge-status">{{ status }}</span>
<button type="button" class="ge-btn" @click="showPreview = !showPreview">
{{ showPreview ? 'Hide preview' : 'Preview' }}
</button>
<button type="button" class="ge-btn" @click="save()" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button v-if="!isNew && canpublish" type="button" class="ge-btn ge-primary"
@click="publish" :disabled="saving">
{{ guide.ispublished ? 'Publish changes' : 'Publish' }}
</button>
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
class="ge-btn" @click="unpublish">Withdraw</button>
<router-link v-if="!isNew && guide.slug" class="ge-btn"
:to="{ name: 'guide-view', params: { slug: guide.slug } }">View</router-link>
</div>
</div>
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="ge-fields">
<input v-model="guide.title" type="text" class="ge-title"
placeholder="Guide title">
<input v-model="guide.summary" type="text"
placeholder="One line: what this covers and when somebody needs it">
<input v-model="guide.tags" type="text"
placeholder="Tags, comma separated - e.g. partmarker, backups">
</div>
<!-- The blank page is the barrier, so offer a shape rather than an empty
canvas. Only on a guide that has nothing in it yet. -->
<div v-if="!blocks.length" class="ge-templates">
<span>Start from:</span>
<button v-for="tpl in templates" :key="tpl.key" type="button"
class="ge-tpl" @click="useTemplate(tpl)">
<strong>{{ tpl.name }}</strong>
<small>{{ tpl.description }}</small>
</button>
</div>
<!--
Proofing: warnings, never a block. Somebody publishing at 14:40 with one
empty bullet should be told, not stopped - the tool does not know the
bullet is deliberate, and a procedure that exists imperfectly beats one
nobody wrote.
-->
<div v-if="issues.length" class="ge-proof">
<div class="ge-proofhead">
<strong>{{ errorCount }} to fix, {{ issues.length - errorCount }} worth a look</strong>
<button type="button" @click="showProof = !showProof">
{{ showProof ? 'Hide' : 'Show' }}
</button>
</div>
<ul v-if="showProof">
<li v-for="(item, i) in issues" :key="i" :class="'ge-' + item.level">
<button v-if="item.blockid" type="button" class="ge-jump"
@click="jumpTo(item.blockid)">go</button>
<span>{{ item.message }}</span>
<button v-if="item.fix" type="button" class="ge-fix"
@click="fix(item)">fix it</button>
</li>
</ul>
</div>
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor">
<div v-for="(block, index) in blocks" :key="block.id"
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
@focusin="focused = block.id">
<div class="ge-blockbar">
<span class="ge-type">{{ block.type }}</span>
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="success">success</option>
</select>
<input v-if="block.type === 'code'" v-model="block.language"
class="ge-mini" placeholder="language">
<input v-if="block.type === 'code'" v-model="block.caption"
class="ge-mini" placeholder="caption">
<!-- Formatting acts on the selection in this block's textarea, so
nobody has to know the markup exists. -->
<div v-if="hasText(block) && block.type !== 'code'" class="ge-format">
<button type="button" title="Bold" @click="wrap(block, '**')"><b>B</b></button>
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
<button type="button" title="Code" @click="wrap(block, '`')">&lt;&gt;</button>
<button type="button" title="Link" @click="makeLink(block)">link</button>
</div>
<div class="ge-blockbtns">
<button type="button" title="Move up" @click="move(index, -1)"
:disabled="index === 0">^</button>
<button type="button" title="Move down" @click="move(index, 1)"
:disabled="index === blocks.length - 1">v</button>
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
<button type="button" class="ge-del" title="Remove"
@click="remove(index)">x</button>
</div>
</div>
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
placeholder="Step title">
<!--
Spellcheck and autocorrect are ON for prose and OFF for code.
A code block that autocorrects is a guide that tells somebody to run
a command which does not work: smart quotes replacing ", an
autocapitalised cmdlet, a "corrected" flag. The author will not
notice, because it still LOOKS right - and the person at the bay
gets a syntax error with no idea why.
-->
<textarea v-if="hasText(block)" v-model="block.text"
:ref="el => registerArea(block.id, el)"
class="ge-text" :class="{ 'ge-mono': block.type === 'code' }"
:rows="block.type === 'code' ? 6 : 3"
:spellcheck="block.type !== 'code'"
:autocorrect="block.type === 'code' ? 'off' : 'on'"
:autocapitalize="block.type === 'code' ? 'off' : 'sentences'"
:autocomplete="block.type === 'code' ? 'off' : undefined"
:placeholder="placeholderFor(block)"></textarea>
<div v-if="hasItems(block)" class="ge-items">
<div v-for="(item, i) in block.items" :key="item.id" class="ge-item">
<span class="ge-bullet">{{ block.type === 'numbers' ? (i + 1) + '.' : '-' }}</span>
<input v-model="item.text" :placeholder="'Item ' + (i + 1)"
:ref="el => registerItem(item.id, el)"
@keydown.enter.prevent="addItemAfter(block, i)"
@keydown.backspace="maybeRemoveItem(block, i, $event)">
<button type="button" @click="removeItem(block, i)">x</button>
</div>
<button type="button" class="ge-additem" @click="addItemAfter(block, block.items.length - 1)">
Add item
</button>
</div>
<div v-if="block.type === 'image'" class="ge-image">
<!-- Same authenticated fetch as the reader's view: an author must
see the screenshot they just pasted. -->
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
Image unavailable - it may have been removed
</p>
<input v-model="block.caption" placeholder="Caption (optional)">
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
</div>
<div v-if="block.type === 'table'" class="ge-tableedit">
<textarea v-model="block.raw" rows="4" @input="parseTable(block)"
spellcheck="false" autocorrect="off" autocapitalize="off"
placeholder="One row per line, cells separated by |&#10;Header | Header&#10;Cell | Cell"></textarea>
</div>
</div>
<div class="ge-add">
<span>Add:</span>
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden @change="onFilePick">
</label>
</div>
<p class="ge-hint">
Paste or drag an image anywhere to drop it in below the block you are working on.
Text supports **bold**, *italic*, `code` and [links](https://example.com) -
or use the buttons.
</p>
</div>
<!-- The same renderer readers get, so what you see here is what they see.
A separate preview implementation would drift within a month. -->
<div v-if="showPreview" class="ge-preview">
<div class="ge-previewhead">Preview</div>
<h1 class="ge-previewtitle">{{ guide.title || 'Untitled guide' }}</h1>
<p v-if="guide.summary" class="ge-previewsummary">{{ guide.summary }}</p>
<GuideBlocks :blocks="blocks" :interactive="false" :ticked="{}" />
</div>
</div>
<div v-if="!isNew && revisions.length" class="ge-revisions">
<h3>Version history</h3>
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
<span class="ge-revnum">v{{ rev.revision }}</span>
<span class="ge-revwhen">{{ formatDate(rev.createdat) }}</span>
<span v-if="rev.ispublished" class="ge-revpub">published</span>
<span class="ge-revnote">{{ rev.note }}</span>
<button type="button" @click="restore(rev)">Restore</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js'
import { proof, applyFix } from '../utils/proof.js'
import { useGuideImages } from '../utils/images.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
'numbers', 'callout', 'code', 'table']
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
slug: '', ispublished: false })
const blocks = ref([])
const revisions = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
const status = ref('')
const showPreview = ref(true)
const focused = ref('')
const dirty = ref(false)
const showProof = ref(true)
// Recomputed as you type. Cheap - it is a walk over a few dozen blocks - and
// live feedback is the difference between proofing and a report nobody reads.
const issues = computed(() => proof(guide, blocks.value))
const errorCount = computed(() => issues.value.filter(i => i.level === 'error').length)
function jumpTo (blockid) {
focused.value = blockid
const node = textareas[blockid] || itemInputs[blockid]
if (node?.scrollIntoView) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' })
node.focus?.()
}
}
function fix (item) {
const block = blocks.value.find(b => b.id === item.blockid)
if (block && applyFix(block, item.fix)) dirty.value = true
}
const isNew = computed(() => !guide.guideid)
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
const textareas = {}
const itemInputs = {}
function registerArea (id, el) { if (el) textareas[id] = el }
function registerItem (id, el) { if (el) itemInputs[id] = el }
function newid () {
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
return Math.random().toString(36).slice(2, 10)
}
function hasText (block) {
return ['heading', 'paragraph', 'step', 'callout', 'code'].includes(block.type)
}
function hasItems (block) {
return ['checklist', 'bullets', 'numbers'].includes(block.type)
}
function placeholderFor (block) {
if (block.type === 'code') return 'The command, exactly as it should be run'
if (block.type === 'callout') return 'What somebody needs to know before carrying on'
if (block.type === 'heading') return 'Section heading'
return 'Text'
}
function useTemplate (tpl) {
blocks.value = tpl.blocks()
dirty.value = true
}
// New blocks land AFTER the block being worked on, not at the bottom. Appending
// to the end means scrolling back every time, which is the difference between
// writing a guide and fighting an editor.
function insertIndex () {
const index = blocks.value.findIndex(b => b.id === focused.value)
return index === -1 ? blocks.value.length : index + 1
}
function add (type) {
const block = { id: newid(), type }
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
if (type === 'callout') block.variant = 'info'
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
const at = insertIndex()
blocks.value.splice(at, 0, block)
focused.value = block.id
nextTick(() => textareas[block.id]?.focus())
}
function duplicate (index) {
const source = blocks.value[index]
const copy = JSON.parse(JSON.stringify(source))
copy.id = newid()
if (copy.items) copy.items = copy.items.map(item => ({ ...item, id: newid() }))
blocks.value.splice(index + 1, 0, copy)
}
function remove (index) { blocks.value.splice(index, 1) }
function move (index, delta) {
const target = index + delta
if (target < 0 || target >= blocks.value.length) return
const [block] = blocks.value.splice(index, 1)
blocks.value.splice(target, 0, block)
}
// Enter makes the next item and focuses it - the way every list in every other
// tool behaves. Typing a ten-item checklist should not need the mouse.
function addItemAfter (block, index) {
const item = { id: newid(), text: '' }
block.items.splice(index + 1, 0, item)
nextTick(() => itemInputs[item.id]?.focus())
}
// Backspace on an ALREADY EMPTY item removes it and moves up, which is how a
// list stops without reaching for the mouse. Never on a non-empty one.
function maybeRemoveItem (block, index, event) {
if (event.target.value !== '' || block.items.length <= 1) return
event.preventDefault()
const previous = block.items[index - 1]
block.items.splice(index, 1)
if (previous) nextTick(() => itemInputs[previous.id]?.focus())
}
function removeItem (block, index) {
if (block.items.length > 1) block.items.splice(index, 1)
else block.items[0].text = ''
}
function parseTable (block) {
const lines = (block.raw || '').split('\n').filter(l => l.trim())
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
block.header = rows.length ? rows[0] : []
block.rows = rows.slice(1)
}
// Formatting wraps the SELECTION, so the markup stays an implementation detail.
function wrap (block, marker) {
const area = textareas[block.id]
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const text = block.text || ''
const selected = text.slice(start, end) || 'text'
block.text = text.slice(0, start) + marker + selected + marker + text.slice(end)
nextTick(() => {
area.focus()
area.setSelectionRange(start + marker.length, start + marker.length + selected.length)
})
}
function makeLink (block) {
const area = textareas[block.id]
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const text = block.text || ''
const selected = text.slice(start, end) || 'link text'
const url = window.prompt('Link to:', 'https://')
if (!url) return
block.text = text.slice(0, start) + '[' + selected + '](' + url + ')' + text.slice(end)
nextTick(() => area.focus())
}
async function uploadImage (file) {
const form = new FormData()
form.append('file', file)
if (guide.guideid) form.append('guideid', guide.guideid)
const response = await api.post('/guides/images', form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return response.data?.data || response.data
}
async function insertImage (file) {
error.value = ''
status.value = 'Uploading image...'
try {
const image = await uploadImage(file)
// imageid is what the renderer resolves; url stays for older readers and
// for anything that still treats a block as a plain document.
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
url: image.url, caption: '', alt: '' }
blocks.value.splice(insertIndex(), 0, block)
focused.value = block.id
status.value = ''
} catch (err) {
status.value = ''
error.value = apiError(err, 'Could not upload that image')
}
}
function onPaste (event) {
const items = Array.from(event.clipboardData?.items || [])
const imageItem = items.find(item => item.type.startsWith('image/'))
if (!imageItem) return
event.preventDefault()
const file = imageItem.getAsFile()
if (file) insertImage(file)
}
function onDrop (event) {
const file = Array.from(event.dataTransfer?.files || [])
.find(f => f.type.startsWith('image/'))
if (file) insertImage(file)
}
function onFilePick (event) {
const file = event.target.files?.[0]
if (file) insertImage(file)
event.target.value = ''
}
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
const { state: imageState } = useGuideImages(blocks)
function payload () {
return { title: guide.title, summary: guide.summary || '',
tags: guide.tags || '', blocks: blocks.value }
}
// Returns whether the save actually reached the server. publish() depends on
// that answer: a publish that runs after a failed save promotes the LAST
// revision the server accepted, which is not what is on the author's screen.
async function save (quiet) {
if (!guide.title.trim()) {
if (!quiet) error.value = 'A guide needs a title before it can be saved'
return false
}
saving.value = true
error.value = ''
try {
if (isNew.value) {
const response = await api.post('/guides', payload())
const created = response.data?.data || response.data
Object.assign(guide, { guideid: created.guideid, slug: created.slug })
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
} else {
const response = await api.put('/guides/' + guide.guideid, payload())
const saved = response.data?.data || response.data
guide.slug = saved.slug
}
dirty.value = false
status.value = 'Saved ' + new Date().toLocaleTimeString()
await loadRevisions()
return true
} catch (err) {
error.value = apiError(err, 'Could not save this guide')
status.value = 'Not saved'
return false
} finally {
saving.value = false
}
}
// Autosave, debounced. The server dedups on content, so an autosave that
// changed nothing writes no version - which is what makes this safe to run on a
// timer rather than a deliberate act.
let autosaveTimer = null
watch([blocks, () => guide.title, () => guide.summary, () => guide.tags],
() => {
dirty.value = true
status.value = 'Unsaved changes'
if (isNew.value) return // a new guide is saved deliberately, once
clearTimeout(autosaveTimer)
autosaveTimer = setTimeout(() => save(true), 3000)
},
{ deep: true })
onBeforeUnmount(() => clearTimeout(autosaveTimer))
// Leaving with unsaved work should require saying so out loud.
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return window.confirm('This guide has unsaved changes. Leave anyway?')
})
async function publish () {
if (errorCount.value) {
const ok = window.confirm(
errorCount.value + ' problem(s) would go out with this guide, including '
+ 'anything flagged in a command. Publish anyway?')
if (!ok) return
}
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
// in what readers get. Reporting "Published version 4" over a failed save
// tells the author their words went out when the old ones did.
if (!await save(true)) {
error.value = error.value
|| 'Could not save this guide, so it was not published'
return
}
try {
const response = await api.post('/guides/' + guide.guideid + '/publish')
guide.ispublished = true
message.value = response.data?.message || 'Published'
setTimeout(() => { message.value = '' }, 3000)
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not publish this guide')
}
}
async function unpublish () {
try {
await api.post('/guides/' + guide.guideid + '/unpublish')
guide.ispublished = false
message.value = 'Withdrawn from readers'
setTimeout(() => { message.value = '' }, 3000)
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not withdraw this guide')
}
}
async function restore (rev) {
if (!window.confirm('Bring version ' + rev.revision + ' back as a new draft version?')) return
try {
const response = await api.post(
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
const restored = response.data?.data || response.data
blocks.value = restored.blocks || []
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
tags: (restored.tags || []).join(', ') })
dirty.value = false
message.value = response.data?.message || 'Restored'
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not restore that version')
}
}
async function loadRevisions () {
if (!guide.guideid) return
try {
const response = await api.get('/guides/' + guide.guideid + '/revisions')
revisions.value = response.data?.data || response.data || []
} catch (err) {
revisions.value = []
}
}
function formatDate (value) {
return value ? new Date(value).toLocaleString() : ''
}
onMounted(async () => {
const guideid = route.params.guideid
if (!guideid) return
try {
const response = await api.get('/guides/' + guideid + '/working')
const data = response.data?.data || response.data
Object.assign(guide, {
guideid: data.guideid, title: data.title, summary: data.summary || '',
tags: (data.tags || []).join(', '), slug: data.slug,
ispublished: data.ispublished,
})
blocks.value = (data.blocks || []).map(block => {
if (block.type === 'table' && !block.raw) {
const rows = [block.header || [], ...(block.rows || [])]
block.raw = rows.filter(r => r.length).map(r => r.join(' | ')).join('\n')
}
return block
})
await nextTick()
dirty.value = false
status.value = ''
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not load this guide')
}
})
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; }
.ge-actions { display: flex; gap: 0.4rem; align-items: center; }
.ge-status { font-size: 0.75rem; color: var(--text-light); margin-right: 0.3rem; }
.ge-btn {
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
color: var(--primary); background: transparent; border-radius: 4px;
cursor: pointer; text-decoration: none;
}
.ge-primary { background: var(--primary); color: #fff; }
.ge-btn:disabled { opacity: 0.5; cursor: default; }
.ge-fields { display: grid; gap: 0.4rem; margin: 0.8rem 0 1rem; max-width: 45rem; }
.ge-fields input {
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text);
}
.ge-title { font-size: 1.1rem; font-weight: 600; }
.ge-templates { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
margin-bottom: 1rem; }
.ge-templates > span { font-size: 0.8rem; color: var(--text-light); }
.ge-tpl {
display: grid; text-align: left; gap: 0.1rem; padding: 0.45rem 0.7rem;
border: 1px solid var(--border); border-radius: 5px; background: transparent;
color: var(--text); cursor: pointer; max-width: 15rem;
}
.ge-tpl:hover { border-color: var(--primary); }
.ge-tpl small { color: var(--text-light); font-size: 0.72rem; }
.ge-split { display: grid; gap: 1.2rem; }
.ge-haspreview { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@media (max-width: 70rem) { .ge-haspreview { grid-template-columns: 1fr; } }
.ge-block {
border: 1px solid var(--border); border-radius: 5px; padding: 0.5rem;
margin-bottom: 0.5rem;
}
.ge-focused { border-color: var(--primary); }
.ge-blockbar { display: flex; gap: 0.35rem; align-items: center; margin-bottom: 0.35rem;
flex-wrap: wrap; }
.ge-type { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-light); }
.ge-mini {
font-size: 0.72rem; padding: 0.12rem 0.3rem; border: 1px solid var(--border);
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
max-width: 8rem;
}
.ge-format { display: flex; gap: 0.15rem; }
.ge-format button, .ge-blockbtns button {
font-size: 0.7rem; padding: 0.1rem 0.35rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
min-width: 1.5rem;
}
.ge-format button:hover, .ge-blockbtns button:hover { color: var(--primary); border-color: var(--primary); }
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.15rem; }
.ge-del:hover { border-color: var(--danger, #c0392b) !important; color: var(--danger, #c0392b) !important; }
.ge-text, .ge-steptitle, .ge-tableedit textarea {
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
font-family: inherit; font-size: 0.88rem; resize: vertical;
}
.ge-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
.ge-steptitle { margin-bottom: 0.35rem; }
.ge-items { display: grid; gap: 0.25rem; }
.ge-item { display: flex; gap: 0.3rem; align-items: center; }
.ge-bullet { font-size: 0.8rem; color: var(--text-light); width: 1.2rem; }
.ge-item input {
flex: 1; padding: 0.3rem 0.45rem; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
}
.ge-item button, .ge-additem, .ge-add button, .ge-upload {
font-size: 0.72rem; padding: 0.15rem 0.45rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px;
cursor: pointer; justify-self: start;
}
.ge-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
border: 1px solid var(--border); border-radius: 4px; }
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text); }
.ge-add { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin: 0.8rem 0 0.4rem; }
.ge-add > span { font-size: 0.75rem; color: var(--text-light); }
.ge-hint { font-size: 0.75rem; color: var(--text-light); }
.ge-preview {
border-left: 1px solid var(--border); padding-left: 1.2rem;
position: sticky; top: 1rem; align-self: start; max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.ge-previewhead {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-light); margin-bottom: 0.5rem;
}
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; }
.ge-proof {
border: 1px solid var(--border); border-left: 3px solid var(--warning, #d9822b);
border-radius: 4px; padding: 0.5rem 0.7rem; margin-bottom: 1rem;
font-size: 0.82rem;
}
.ge-proofhead { display: flex; align-items: center; gap: 0.5rem; }
.ge-proofhead button {
margin-left: auto; font-size: 0.72rem; background: none; border: none;
color: var(--text-light); cursor: pointer; text-decoration: underline;
}
.ge-proof ul { margin: 0.4rem 0 0; padding: 0; list-style: none; }
.ge-proof li { display: flex; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
.ge-error span { color: var(--danger, #c0392b); }
.ge-warn span { color: var(--text-light); }
.ge-jump, .ge-fix {
font-size: 0.68rem; padding: 0.05rem 0.35rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
}
.ge-fix { margin-left: auto; border-color: var(--primary); color: var(--primary); }
.ge-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
.ge-revnum { font-weight: 600; }
.ge-revwhen, .ge-revnote { color: var(--text-light); }
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
border: 1px solid var(--primary); color: var(--primary); }
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer; }
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="gv">
<div v-if="loading" class="gv-loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="guide">
<div class="gv-head">
<div class="gv-headmain">
<h1>{{ guide.title }}</h1>
<p v-if="guide.summary" class="gv-summary">{{ guide.summary }}</p>
<div class="gv-meta">
<span v-if="guide.isdraftpreview" class="gv-badge gv-draft">Draft preview</span>
<span v-else class="gv-badge">Version {{ guide.revision }}</span>
<span v-for="tag in guide.tags" :key="tag" class="gv-tag"
@click="$router.push({ name: 'guides', query: { tag } })">{{ tag }}</span>
</div>
</div>
<div class="gv-actions">
<button type="button" class="gv-btn" @click="print">Print</button>
<router-link v-if="guide.canedit" class="gv-btn"
:to="{ name: 'guide-edit', params: { guideid: guide.guideid } }">
Edit
</router-link>
</div>
</div>
<!-- An editor needs to know there is newer work than what readers see,
without leaving the page to find out. -->
<div v-if="guide.hasunpublishedchanges && guide.canedit" class="gv-notice">
There are unpublished changes. Readers are seeing version {{ guide.revision }}.
</div>
<div v-if="totalChecks" class="gv-progress">
<div class="gv-progressbar">
<div class="gv-progressfill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="gv-progresstext">{{ tickedCount }} of {{ totalChecks }} done</span>
<button v-if="tickedCount" type="button" class="gv-reset" @click="resetProgress">
Reset
</button>
</div>
<GuideBlocks :blocks="guide.blocks" :interactive="true"
:ticked="ticked" @toggle="toggle" />
<div class="gv-foot">
<span>Version {{ guide.revision }}</span>
<span v-if="guide.publishedat">Published {{ formatDate(guide.publishedat) }}</span>
<span class="gv-printedon">Printed {{ today }}</span>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import GuideBlocks from '../components/GuideBlocks.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const guide = ref(null)
const loading = ref(true)
const error = ref('')
const ticked = ref({})
const today = new Date().toLocaleDateString()
/**
* Progress lives in the BROWSER, not the database.
*
* Shopfloor PCs share a login. Storing progress per user would mean one
* technician's ticks appearing for the next person on that bay, which is worse
* than useless on a procedure - it reads as "already done". Per browser, each
* station keeps its own place, and the Reset button is right there.
*
* Keyed by guide id and version: a new version invalidates old ticks, because
* step 4 of version 3 is not step 4 of version 2.
*/
function storageKey () {
return guide.value
? 'guides.progress.' + guide.value.guideid + '.v' + guide.value.revision
: null
}
function loadProgress () {
const key = storageKey()
if (!key) return
try {
ticked.value = JSON.parse(window.localStorage.getItem(key) || '{}')
} catch (err) {
ticked.value = {}
}
}
function saveProgress () {
const key = storageKey()
if (!key) return
try {
window.localStorage.setItem(key, JSON.stringify(ticked.value))
} catch (err) {
// Private window, or storage disabled. The checkboxes still work for this
// visit; they just will not be remembered. Not worth an error message.
}
}
function toggle (itemid, checked) {
if (checked) ticked.value = { ...ticked.value, [itemid]: true }
else {
const next = { ...ticked.value }
delete next[itemid]
ticked.value = next
}
saveProgress()
}
function resetProgress () {
ticked.value = {}
saveProgress()
}
const totalChecks = computed(() => {
let total = 0
for (const block of guide.value?.blocks || []) {
if (block.type === 'checklist') total += (block.items || []).length
}
return total
})
const tickedCount = computed(() => Object.keys(ticked.value).length)
const progressPercent = computed(() =>
totalChecks.value ? Math.round((tickedCount.value / totalChecks.value) * 100) : 0)
function formatDate (value) {
return value ? new Date(value).toLocaleDateString() : ''
}
function print () {
window.print()
}
async function load () {
loading.value = true
error.value = ''
try {
const response = await api.get('/guides/' + route.params.slug)
guide.value = response.data?.data || response.data
setRecordTitle(guide.value?.title)
loadProgress()
} catch (err) {
error.value = apiError(err, 'Could not load this guide')
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.slug, load)
</script>
<style scoped>
.gv { max-width: 60rem; }
.gv-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
.gv-headmain { flex: 1; }
.gv-head h1 { margin: 0 0 0.3rem; }
.gv-summary { color: var(--text-light); margin: 0 0 0.5rem; }
.gv-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.gv-badge {
font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 3px;
border: 1px solid var(--border); color: var(--text-light);
}
.gv-draft { border-color: var(--warning, #d9822b); color: var(--warning, #d9822b); }
.gv-tag {
font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px;
background: var(--surface-2, rgba(127,127,127,0.1)); cursor: pointer;
}
.gv-tag:hover { color: var(--primary); }
.gv-actions { display: flex; gap: 0.4rem; }
.gv-btn {
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
color: var(--primary); background: transparent; border-radius: 4px;
cursor: pointer; text-decoration: none;
}
.gv-btn:hover { background: var(--primary); color: #fff; }
.gv-notice {
border-left: 4px solid var(--warning, #d9822b);
background: var(--surface-2, rgba(127,127,127,0.08));
padding: 0.5rem 0.8rem; margin: 0.5rem 0 1rem; font-size: 0.85rem;
}
.gv-progress { display: flex; align-items: center; gap: 0.7rem; margin: 1rem 0 1.4rem; }
.gv-progressbar {
flex: 1; height: 6px; border-radius: 3px; overflow: hidden;
background: var(--surface-2, rgba(127,127,127,0.15));
}
.gv-progressfill { height: 100%; background: var(--primary); transition: width 0.2s; }
.gv-progresstext { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; }
.gv-reset {
font-size: 0.72rem; background: none; border: none; color: var(--text-light);
cursor: pointer; text-decoration: underline;
}
.gv-foot {
margin-top: 2rem; padding-top: 0.6rem; border-top: 1px solid var(--border);
display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-light);
}
.gv-printedon { display: none; }
@media print {
/* A printed guide is a worksheet: no controls, everything visible, and the
footer says which version it was so a stale printout on a noticeboard can
be spotted. */
.gv-actions, .gv-progress, .gv-notice, .gv-reset { display: none !important; }
.gv-printedon { display: inline; }
.gv { max-width: none; }
.gv-head h1 { font-size: 1.6rem; }
.gv-foot { border-top: 1px solid #999; }
}
</style>
<style>
/*
* PRINT IS ALWAYS LIGHT, whatever the screen theme is.
*
* Printing a dark-mode page lays down a full page of toner for the background
* and then knocks the text out of it in white. On a shopfloor printer that is
* a cartridge for a handful of guides, and the result is harder to read than
* the plain version.
*
* So the theme tokens are redefined for print only. Every guide element already
* renders through these variables rather than literal colours, which is what
* makes this a dozen lines instead of a rewrite - and the same discipline that
* made dark mode work at all.
*
* Deliberately NOT scoped: a scoped block cannot reach :root, and these have to
* override tokens set on the document element.
*/
@media print {
:root,
:root[data-theme="dark"],
:root[data-theme="light"] {
--text: #000;
--text-light: #333;
--border: #999;
--surface: #fff;
--surface-2: #fff;
--primary: #000;
--info: #666;
--warning: #666;
--success: #666;
}
html, body, .app-layout, .main-content {
background: #fff !important;
color: #000 !important;
}
/* Backgrounds are dropped rather than printed grey: a callout is identified
by its rule and its heading, which cost nothing to print. */
.gb-callout, .gb-code, .gb-codehead, .gb-table th {
background: #fff !important;
}
.gb-callout { border-left: 3px solid #000 !important; }
.gb-code, .gb-table th, .gb-table td { border-color: #999 !important; }
/* Links: the text is what matters on paper, and a blue underline on a mono
printer is just grey. */
a { color: #000 !important; text-decoration: underline; }
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<div class="gl">
<div class="page-header">
<h2>Guides</h2>
<router-link v-if="cancreate" class="gl-new" :to="{ name: 'guide-new' }">
New guide
</router-link>
</div>
<div class="gl-filters">
<input v-model="search" type="search" class="gl-search"
placeholder="Search titles, summaries, tags and content..."
@input="debouncedLoad">
<div v-if="tags.length" class="gl-tags">
<button type="button" class="gl-tag" :class="{ 'gl-tagon': !activeTag }"
@click="setTag('')">All</button>
<button v-for="tag in tags" :key="tag.tag" type="button"
class="gl-tag" :class="{ 'gl-tagon': activeTag === tag.tag }"
@click="setTag(tag.tag)">
{{ tag.tag }} <span class="gl-count">{{ tag.count }}</span>
</button>
</div>
</div>
<div v-if="loading" class="gl-loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<div v-else-if="!guides.length" class="gl-empty">
<p v-if="search || activeTag">Nothing matches that.</p>
<p v-else>No guides yet.</p>
</div>
<div v-else class="gl-grid">
<router-link v-for="guide in guides" :key="guide.guideid" class="gl-card"
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
<div class="gl-cardhead">
<h3>{{ guide.title }}</h3>
<!-- Drafts are visible here only to people who can edit them, so the
badge is a working state rather than a warning. -->
<span v-if="!guide.ispublished" class="gl-draft">Draft</span>
</div>
<p v-if="guide.summary" class="gl-summary">{{ guide.summary }}</p>
<div class="gl-cardfoot">
<span v-for="tag in guide.tags" :key="tag" class="gl-minitag">{{ tag }}</span>
<span v-if="guide.checklistitems" class="gl-checks">
{{ guide.checklistitems }} steps to tick
</span>
</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const guides = ref([])
const tags = ref([])
const search = ref('')
const activeTag = ref('')
const loading = ref(true)
const error = ref('')
// computed, not a one-off read: permissions may not be loaded yet at setup.
const cancreate = computed(() => auth.hasPermission?.('guides.create') ?? false)
let timer = null
function debouncedLoad () {
clearTimeout(timer)
timer = setTimeout(load, 250)
}
function setTag (tag) {
activeTag.value = tag
router.replace({ query: tag ? { tag } : {} })
load()
}
async function load () {
loading.value = true
error.value = ''
try {
const params = {}
if (search.value.trim()) params.q = search.value.trim()
if (activeTag.value) params.tag = activeTag.value
const response = await api.get('/guides', { params })
guides.value = response.data?.data || response.data || []
} catch (err) {
error.value = apiError(err, 'Could not load guides')
} finally {
loading.value = false
}
}
async function loadTags () {
try {
const response = await api.get('/guides/tags')
tags.value = response.data?.data || response.data || []
} catch (err) {
tags.value = []
}
}
onMounted(() => {
activeTag.value = route.query.tag || ''
load()
loadTags()
})
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; }
.gl-new {
font-size: 0.82rem; padding: 0.35rem 0.8rem; border-radius: 4px;
background: var(--primary); color: #fff; text-decoration: none;
}
.gl-filters { margin: 0.8rem 0 1.2rem; }
.gl-search {
width: 100%; max-width: 34rem; padding: 0.45rem 0.6rem;
border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text);
}
.gl-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
.gl-tag {
font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); cursor: pointer;
}
.gl-tagon { border-color: var(--primary); color: var(--primary); }
.gl-count { opacity: 0.6; }
.gl-grid {
display: grid; gap: 0.8rem;
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
}
.gl-card {
display: block; padding: 0.9rem 1rem; border: 1px solid var(--border);
border-radius: 6px; text-decoration: none; color: inherit;
background: var(--surface, transparent);
}
.gl-card:hover { border-color: var(--primary); }
.gl-cardhead { display: flex; align-items: baseline; gap: 0.5rem; }
.gl-cardhead h3 { margin: 0; font-size: 1rem; flex: 1; }
.gl-draft {
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 3px;
border: 1px solid var(--warning, #d9822b); color: var(--warning, #d9822b);
}
.gl-summary { margin: 0.4rem 0 0.6rem; font-size: 0.85rem; color: var(--text-light); }
.gl-cardfoot { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
.gl-minitag {
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 999px;
background: var(--surface-2, rgba(127,127,127,0.1)); color: var(--text-light);
}
.gl-checks { font-size: 0.7rem; color: var(--text-light); margin-left: auto; }
.gl-empty, .gl-loading { color: var(--text-light); padding: 1.5rem 0; }
</style>

View File

@@ -0,0 +1,16 @@
"""Alembic environment for the guides plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
the metadata to this plugin's tables and drives Alembic against the per-plugin
version table alembic_version_guides. See ADR-008 for the ownership model.
Like the backups plugin and unlike the cutover ones, guides is NEW: its 0001
baseline really CREATES its tables, because the core chain never built them.
"""
import os
os.environ['PLUGIN_NAME'] = 'guides'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,114 @@
"""guides plugin baseline: guides, guiderevisions, guideimages.
A new plugin, so this builds its tables rather than stamping an anchor over
core-owned ones (contrast the cutover plugins, whose 0001 is a no-op).
Idempotent: each table is created only if it is not already there, so a database
where an earlier run half-succeeded can be upgraded again without hand-editing
alembic_version_guides.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = 'guides0001baseline'
down_revision = None
branch_labels = None
depends_on = None
def _tables():
return set(inspect(op.get_bind()).get_table_names())
def upgrade():
existing = _tables()
if 'guides' not in existing:
op.create_table(
'guides',
sa.Column('guideid', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
# 191 because this is UNIQUE-indexed and MySQL caps an index key at
# 767 bytes; utf8mb4 is 4 bytes per character.
sa.Column('slug', sa.String(length=191), nullable=False),
sa.Column('summary', sa.String(length=1000)),
sa.Column('tags', sa.String(length=500)),
sa.Column('status', sa.String(length=20), nullable=False,
server_default='draft'),
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
nullable=False),
sa.Column('publishedrevisionid', sa.Integer()),
sa.Column('createdby', sa.Integer()),
sa.Column('updatedby', sa.Integer()),
sa.Column('publishedat', sa.DateTime()),
# BaseModel contributes these to every model in the app; a plugin
# migration has to spell them out or the table is missing columns
# the ORM selects on every query.
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('revision', sa.Integer(), nullable=False, server_default='1'),
sa.PrimaryKeyConstraint('guideid'),
sa.UniqueConstraint('slug', name='uq_guides_slug'),
)
op.create_index('idx_guides_status', 'guides', ['status'])
if 'guiderevisions' not in existing:
op.create_table(
'guiderevisions',
sa.Column('guiderevisionid', sa.Integer(), nullable=False),
sa.Column('guideid', sa.Integer(), nullable=False),
sa.Column('revision', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('summary', sa.String(length=1000)),
sa.Column('tags', sa.String(length=500)),
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
nullable=False),
sa.Column('contenthash', sa.String(length=64), nullable=False),
sa.Column('note', sa.String(length=500)),
sa.Column('createdby', sa.Integer()),
# BaseModel contributes these to every model in the app; a plugin
# migration has to spell them out or the table is missing columns
# the ORM selects on every query.
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('guiderevisionid'),
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
ondelete='CASCADE'),
)
op.create_index('idx_guiderevisions_guide', 'guiderevisions', ['guideid'])
op.create_index('idx_guiderevisions_hash', 'guiderevisions', ['contenthash'])
if 'guideimages' not in existing:
op.create_table(
'guideimages',
sa.Column('guideimageid', sa.Integer(), nullable=False),
sa.Column('guideid', sa.Integer()),
sa.Column('filename', sa.String(length=255)),
sa.Column('mimetype', sa.String(length=100), nullable=False),
sa.Column('bytesize', sa.Integer(), nullable=False),
sa.Column('data', sa.dialects.mysql.MEDIUMBLOB()
if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(),
nullable=False),
sa.Column('uploadedby', sa.Integer()),
# BaseModel contributes these to every model in the app; a plugin
# migration has to spell them out or the table is missing columns
# the ORM selects on every query.
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('guideimageid'),
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
ondelete='CASCADE'),
)
op.create_index('idx_guideimages_guide', 'guideimages', ['guideid'])
def downgrade():
for table in ('guideimages', 'guiderevisions', 'guides'):
op.drop_table(table)

View File

@@ -1,3 +1,3 @@
from .guide import Guide, GuideImage, BLOCKTYPES
from .guide import Guide, GuideRevision, GuideImage, BLOCKTYPES
__all__ = ['Guide', 'GuideImage', 'BLOCKTYPES']
__all__ = ['Guide', 'GuideRevision', 'GuideImage', 'BLOCKTYPES']

View File

@@ -33,8 +33,7 @@ without an author thinking about it.
import json
from datetime import datetime
from shopdb.api import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
def _utcnow():
@@ -74,7 +73,12 @@ class Guide(BaseModel):
# derived on read so an edited title cannot silently break a link somebody
# pasted into a work order.
title = db.Column(db.String(300), nullable=False)
slug = db.Column(db.String(300), nullable=False, unique=True, index=True)
# 191, not 300: this column is UNIQUE-indexed, and MySQL caps an index key
# at 767 bytes. utf8mb4 stores up to 4 bytes per character, so 191 * 4 =
# 764 is the longest indexable string - 300 produces
# "Specified key was too long" the moment the migration runs on a real
# database, having passed every SQLite test.
slug = db.Column(db.String(191), nullable=False, unique=True, index=True)
# One line, shown in the list and in search results.
summary = db.Column(db.String(1000))
@@ -93,15 +97,39 @@ class Guide(BaseModel):
# the same reason the backups plugin stores its projection as text.
blocksjson = db.Column(db.Text(length=16777215), nullable=False, default='[]')
# What READERS see. Points at an immutable snapshot in guiderevisions, not
# at the working copy above.
#
# This is the whole point of separating the two: without it, editing a live
# procedure changes it under a technician who is halfway through following
# it. With it, they keep reading the published version until somebody
# deliberately publishes a new one. NULL means never published - a draft
# nobody outside the authors can see.
#
# use_alter, because this and guiderevisions.guideid form a cycle: a guide
# points at its published revision, and every revision points back at its
# guide. Without it SQLAlchemy cannot order CREATE/DROP and warns that it
# cannot sort the tables. The database-level constraint is deliberately not
# created by the migration either - the cycle buys nothing there, and the
# pointer is maintained in one place (services/authoring.py).
publishedrevisionid = db.Column(
db.Integer, db.ForeignKey('guiderevisions.guiderevisionid',
ondelete='SET NULL',
use_alter=True,
name='fk_guides_publishedrevision'))
# createddate / modifieddate / isactive come from BaseModel - no second set
# of timestamps on top of them, or the next person has to work out which
# pair is authoritative. publishedat is ours because "when did readers start
# seeing this" is a different fact from "when was the row last touched".
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
updatedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow,
onupdate=_utcnow)
publishedat = db.Column(db.DateTime)
# Bumped on every save. Cheap, and it gives a printed copy something
# honest to put in its footer: "version 7, printed 26 Aug 2026".
# The working copy's version number. Bumped only when a save actually
# changes something, so it counts real edits rather than keystrokes, and a
# printed copy has something honest for its footer: "version 7 (published),
# printed 26 Aug 2026".
revision = db.Column(db.Integer, nullable=False, default=1)
@property
@@ -138,8 +166,10 @@ class Guide(BaseModel):
'tags': self.taglist(),
'status': self.status,
'revision': self.revision,
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None,
# API names stay createdat/updatedat: the column names are a
# storage detail, and renaming a payload field breaks callers.
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
'updatedat': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
'publishedat': (self.publishedat.isoformat() + 'Z'
if self.publishedat else None),
}
@@ -148,6 +178,76 @@ class Guide(BaseModel):
return data
class GuideRevision(BaseModel):
"""An immutable snapshot of a guide, written when a save changes something.
Full snapshots rather than diffs. A guide is small, and a snapshot answers
the question people actually ask - "what did this say in March, when the
job last ran" - without replaying a chain of deltas to find out. The same
reasoning the part-marker backups follow, for the same reason: the version
you need is the one nobody thought to keep.
DEDUP: a save whose content hashes identically to the latest revision writes
nothing. Without that, autosave produces a hundred identical versions a day
and the history becomes noise that hides the three edits that mattered.
Never updated after insert. A revision that can be edited is not a record of
anything.
"""
__tablename__ = 'guiderevisions'
guiderevisionid = db.Column(db.Integer, primary_key=True)
guideid = db.Column(db.Integer,
db.ForeignKey('guides.guideid', ondelete='CASCADE'),
nullable=False, index=True)
# Monotonic per guide, and shown to people: "version 7".
revision = db.Column(db.Integer, nullable=False)
# The whole guide as it stood, not just the blocks: a title or tag change is
# a change worth being able to see and roll back.
title = db.Column(db.String(300), nullable=False)
summary = db.Column(db.String(1000))
tags = db.Column(db.String(500))
blocksjson = db.Column(db.Text(length=16777215), nullable=False)
# sha256 of the canonical content, and the dedup key.
contenthash = db.Column(db.String(64), nullable=False, index=True)
# Optional "what changed and why", the way a commit message earns its keep.
note = db.Column(db.String(500))
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
@property
def blocks(self):
if not self.blocksjson:
return []
try:
data = json.loads(self.blocksjson)
except (ValueError, TypeError):
return []
return data if isinstance(data, list) else []
def to_dict(self, includeblocks=False):
data = {
'guiderevisionid': self.guiderevisionid,
'guideid': self.guideid,
'revision': self.revision,
'title': self.title,
'summary': self.summary,
'tags': [t.strip() for t in (self.tags or '').split(',') if t.strip()],
'note': self.note,
'createdby': self.createdby,
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
'shorthash': (self.contenthash or '')[:12],
}
if includeblocks:
data['blocks'] = self.blocks
return data
class GuideImage(BaseModel):
"""An image pasted or dropped into a guide.
@@ -174,7 +274,6 @@ class GuideImage(BaseModel):
data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False)
uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
def to_dict(self):
return {
@@ -184,5 +283,5 @@ class GuideImage(BaseModel):
'mimetype': self.mimetype,
'bytesize': self.bytesize,
'url': '/api/guides/images/{}'.format(self.guideimageid),
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
}

92
plugins/guides/plugin.py Normal file
View File

@@ -0,0 +1,92 @@
"""Guides plugin.
Step-by-step procedures written and read inside ShopDB: images, working
checklists, dark mode and printable output, with drafts and revision history.
Distinct from the knowledgebase plugin, which is a LINK REGISTRY - a
description, a URL and keywords pointing at a document held somewhere else.
This holds the document. They share a tag vocabulary on purpose, so one search
can cover both.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .models import Guide, GuideRevision, GuideImage
from .api import guides_bp
logger = logging.getLogger(__name__)
class GuidesPlugin(BasePlugin):
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as fh:
return json.load(fh)
return {}
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'guides'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'Step-by-step guides'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/guides'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return guides_bp
def get_models(self) -> List[Type]:
return [Guide, GuideRevision, GuideImage]
def get_permissions(self) -> List:
"""Five permissions, and the split between the last two is the point.
publish is separate from edit so a technician can write up a procedure
while somebody else decides it is fit for the floor. Without that split,
anyone who can fix a typo can also put an unreviewed procedure in front
of an operator.
create without edit is a working combination on purpose: an author may
edit their OWN guide while it is unpublished, and needs guides.edit once
other people are following it (see _mayedit in the API).
"""
return [
('guides.view', 'View guides', 'guides'),
('guides.create', 'Create guides and edit your own drafts', 'guides'),
('guides.edit', 'Edit any guide, including published ones', 'guides'),
('guides.publish', 'Publish or withdraw a guide', 'guides'),
('guides.delete', 'Delete guides', 'guides'),
]
def get_navigation_items(self) -> List[Dict]:
# Position 8, beside Slides (7), rather than down in the Information
# section with the reference lists. A guide is something you go and DO
# something with, like a slide deck you put on a screen - not a
# catalogue you look things up in.
return [
{
'name': 'Guides',
'icon': 'book-open',
'route': '/guides',
'position': 8,
},
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info('Guides plugin initialized (v%s)', self.meta.version)

View File

@@ -0,0 +1,133 @@
"""Saving, revisioning and publishing a guide.
Kept out of the API layer so the rules are testable without a request, and so
there is exactly ONE path that writes a guide. A second write path is how a
system ends up with revisions that skipped the dedup, or a published pointer
that points at nothing.
"""
from datetime import datetime
from shopdb.api import db
from ..models import Guide, GuideRevision
from .blocks import validate, contenthash
def _utcnow():
return datetime.utcnow()
def save(guide, title=None, summary=None, tags=None, blocks=None, note=None,
userid=None):
"""Apply an edit to the working copy, writing a revision if it changed.
Returns (guide, revision_or_None). None means the save was a no-op: the
content hashed identically to the last revision, so nothing was recorded.
That is the normal outcome of an autosave where the author changed nothing,
and it is what keeps the history readable.
"""
if title is not None:
guide.title = title.strip()
if summary is not None:
guide.summary = summary.strip() or None
if tags is not None:
# Normalised once, here: trimmed, empties dropped, order preserved.
# Tags are compared case-insensitively when hashing, but stored as the
# author typed them - "PXE" should not become "pxe" on screen.
guide.tags = ', '.join(t.strip() for t in tags.split(',') if t.strip()) or None
if blocks is not None:
guide.blocks = validate(blocks)
incoming = contenthash(guide.title, guide.summary, guide.tags, guide.blocks)
latest = (GuideRevision.query
.filter_by(guideid=guide.guideid)
.order_by(GuideRevision.revision.desc())
.first())
if latest is not None and latest.contenthash == incoming:
# Nothing a reader would notice has changed. Touch nothing else: an
# updatedat that moves on every autosave makes "last edited" a lie.
return guide, None
guide.revision = (latest.revision + 1) if latest else 1
guide.updatedby = userid
revision = GuideRevision(
guideid=guide.guideid,
revision=guide.revision,
title=guide.title,
summary=guide.summary,
tags=guide.tags,
blocksjson=guide.blocksjson,
contenthash=incoming,
note=(note or '').strip() or None,
createdby=userid,
)
db.session.add(revision)
db.session.flush()
return guide, revision
def publish(guide, userid=None):
"""Point readers at the current working copy.
Publishing does NOT snapshot: it promotes the latest existing revision. If
it snapshotted, a publish with unsaved edits would create a version nobody
reviewed - which is exactly the accident this workflow exists to prevent.
"""
latest = (GuideRevision.query
.filter_by(guideid=guide.guideid)
.order_by(GuideRevision.revision.desc())
.first())
if latest is None:
raise ValueError('nothing to publish: this guide has no saved content')
guide.publishedrevisionid = latest.guiderevisionid
guide.status = 'published'
guide.publishedat = _utcnow()
guide.updatedby = userid
db.session.flush()
return latest
def unpublish(guide, userid=None):
"""Withdraw a guide from readers without deleting anything.
For a procedure that turns out to be wrong. The revisions stay, so it can be
corrected and published again, and so there is still a record of what people
were being told in the meantime.
"""
guide.publishedrevisionid = None
guide.status = 'draft'
guide.updatedby = userid
db.session.flush()
return guide
def readable(guide):
"""The revision a READER should be served, or None.
Never the working copy: a published guide shows what was published, even
while somebody is editing the next version. Editors get the working copy
through the editor, which is a different route on purpose.
"""
if not guide.publishedrevisionid:
return None
return db.session.get(GuideRevision, guide.publishedrevisionid)
def restore(guide, revision, userid=None):
"""Copy an old revision back onto the working copy.
Deliberately NOT a rollback of history: restoring writes a NEW revision with
the old content. The record of what was published between then and now stays
intact, which is the difference between a history and a rewrite.
"""
return save(guide,
title=revision.title,
summary=revision.summary or '',
tags=revision.tags or '',
blocks=revision.blocks,
note='Restored from version {}'.format(revision.revision),
userid=userid)

View File

@@ -17,6 +17,7 @@ Storing text verbatim keeps what the author typed recoverable even if the
rendering rules change later.
"""
import hashlib
import json
from ..models import BLOCKTYPES
@@ -129,3 +130,23 @@ def checklistitemcount(blocks):
if isinstance(block, dict) and block.get('type') == 'checklist':
total += len(block.get('items') or [])
return total
def contenthash(title, summary, tags, blocks):
"""sha256 over everything a reader would notice changing.
Title, summary and tags are included, not just the blocks: renaming a guide
or retagging it is a change worth a revision, and hashing only the body
would silently drop it from the history.
Canonical by construction - the blocks are re-serialized with sorted keys
and no whitespace, so the same content saved twice hashes the same however
the editor happened to order its JSON that day.
"""
canonical = json.dumps({
'title': (title or '').strip(),
'summary': (summary or '').strip(),
'tags': sorted(t.strip().lower() for t in (tags or '').split(',') if t.strip()),
'blocks': blocks or [],
}, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()

View File

@@ -1,212 +1,214 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
setRecordTitle(article.value?.shortdescription)
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>

View File

@@ -12,6 +12,7 @@
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -248,7 +249,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -257,8 +258,13 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
@@ -271,6 +277,7 @@ onMounted(async () => {
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
setRecordTitle(machine.value?.assetnumber || machine.value?.name)
} catch (error) {
console.error('Error loading machine:', error)
} finally {
@@ -291,6 +298,23 @@ function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Retire, not delete: the server deactivates the asset, so the machine
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await machinesApi.delete(machine.value?.machine?.machineid)
toast.success(`Retired ${label}`)
router.push('/machines')
} catch (error) {
console.error('Error retiring machine:', error)
toast.error(apiError(error, 'Failed to retire machine'))
}
}
</script>
<style scoped>

View File

@@ -43,7 +43,7 @@ export default [
path: 'reports/calibration',
name: 'calibration-report',
component: () => import('./views/CalibrationReport.vue'),
meta: { plugin: 'measuringtools' }
meta: { plugin: 'measuringtools', title: 'Calibration Report' }
},
{
path: 'settings/measuringtooltypes',

View File

@@ -9,6 +9,7 @@
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -165,15 +166,20 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
@@ -193,12 +199,30 @@ onMounted(async () => {
? await measuringtoolsApi.getByAsset(route.params.assetid)
: await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data
setRecordTitle(tool.value?.assetnumber || tool.value?.name)
} catch (err) {
console.error('Error loading measuring tool:', err)
} finally {
loading.value = false
}
})
// Retire, not delete: the server deactivates the asset, so the measuring tool
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await measuringtoolsApi.remove(tool.value?.measuringtool?.measuringtoolid)
toast.success(`Retired ${label}`)
router.push('/measuringtools')
} catch (error) {
console.error('Error retiring measuring tool:', error)
toast.error(apiError(error, 'Failed to retire measuring tool'))
}
}
</script>
<style scoped>

View File

@@ -228,6 +228,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
@@ -250,6 +251,7 @@ async function loadDevice() {
try {
const response = await networkApi.get(deviceId)
device.value = response.data.data
setRecordTitle(device.value?.assetnumber || device.value?.name)
} catch (error) {
console.error('Error loading device:', error)
device.value = null

View File

@@ -59,6 +59,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const subnet = ref(null)
@@ -79,6 +80,7 @@ async function load() {
try {
const response = await networkApi.subnets.get(route.params.id)
subnet.value = response.data?.data || response.data
setRecordTitle(subnet.value?.name || subnet.value?.cidr)
} finally {
loading.value = false
}

View File

@@ -187,6 +187,7 @@ import { useRoute } from 'vue-router'
import { printedpartsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import Modal from '@/components/Modal.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const item = ref(null)
@@ -196,6 +197,7 @@ onMounted(async () => {
try {
const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data
setRecordTitle(item.value?.itemname || item.value?.itemcode)
await loadFiles()
} catch (loadError) {
console.error('Error loading printed item:', loadError)

View File

@@ -3,6 +3,15 @@
The gage lab issues WJRP asset numbers for printed parts; the internal
itemcode stays auto-generated, and this optional unique tag carries the
lab's number. The kiosk resolves scans/typed digits against both.
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
column already exists (e.g. a test DB built by db.create_all() from the model).
Unguarded, this failed `flask plugin upgrade-all` on any create-all-built
database and took 0004 down with it, since a chain stops at its first error.
The index is matched by COLUMN SET, not by name: create_all names indexes by
SQLAlchemy's convention and this migration by the repo's, so a name-keyed guard
would add a second index over the same column rather than skip.
"""
from alembic import op
import sqlalchemy as sa
@@ -15,13 +24,31 @@ branch_labels = None
depends_on = None
def _indexed(inspector):
return any(tuple(ix.get('column_names') or ()) == ('gagelabtag',)
for ix in inspector.get_indexes('printeditems'))
def upgrade():
op.add_column('printeditems',
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
['gagelabtag'], unique=True)
inspector = sa.inspect(op.get_bind())
if 'printeditems' not in inspector.get_table_names():
return
columns = {column['name'] for column in inspector.get_columns('printeditems')}
if 'gagelabtag' not in columns:
op.add_column('printeditems',
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
inspector = sa.inspect(op.get_bind())
if not _indexed(inspector):
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
['gagelabtag'], unique=True)
def downgrade():
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
op.drop_column('printeditems', 'gagelabtag')
inspector = sa.inspect(op.get_bind())
if 'printeditems' not in inspector.get_table_names():
return
if _indexed(inspector):
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
columns = {column['name'] for column in inspector.get_columns('printeditems')}
if 'gagelabtag' in columns:
op.drop_column('printeditems', 'gagelabtag')

View File

@@ -6,13 +6,13 @@ export default [
path: 'reports/toner',
name: 'toner-report',
component: () => import('./views/TonerReport.vue'),
meta: { plugin: 'printers' }
meta: { plugin: 'printers', title: 'Toner Report' }
},
{
path: 'reports/toner-forecast',
name: 'toner-forecast',
component: () => import('./views/TonerForecast.vue'),
meta: { plugin: 'printers' }
meta: { plugin: 'printers', title: 'Toner Forecast' }
},
{
path: 'settings/printertypes',

View File

@@ -10,6 +10,7 @@
Print Label
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
@@ -296,7 +297,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
@@ -305,8 +306,13 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
@@ -344,6 +350,9 @@ onMounted(async () => {
])
printer.value = printerRes.data.data
// the page's own heading logic, which already discards the literal
// name 'NONE' that a discovered queue can carry
setRecordTitle(displayTitle.value)
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
// drivers are attached to the printer detail, matched by model
@@ -382,6 +391,23 @@ function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
// Retire, not delete: the server deactivates the asset, so the printer
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
async function confirmRetire() {
const label = displayTitle.value || 'this printer'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await printersApi.remove(route.params.id)
toast.success(`Retired ${label}`)
router.push('/printers')
} catch (error) {
console.error('Error retiring printer:', error)
toast.error(apiError(error, 'Failed to retire printer'))
}
}
</script>
<style scoped>

View File

@@ -160,6 +160,7 @@ import { usbApi } from '@/api'
import Modal from '@/components/Modal.vue'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
@@ -181,6 +182,7 @@ async function loadDevice() {
try {
const response = await usbApi.get(route.params.id)
device.value = response.data.data
setRecordTitle(device.value?.label || device.value?.serialnumber)
} catch (error) {
console.error('Error loading USB device:', error)
} finally {

View File

@@ -18,7 +18,7 @@ export default [
path: 'reports/warranty',
name: 'warranty-report',
component: () => import('./views/WarrantyReport.vue'),
meta: { plugin: 'warranty' }
meta: { plugin: 'warranty', title: 'Warranty Report' }
}
]

View File

@@ -233,17 +233,30 @@ echo "==> Checking for site-specific literals in product code (ADR-015)..."
# publication scrub greps for the very strings below - written literally, the
# rule's own definition trips the gate that enforces it. Same reason
# tests/test_docs_publishable.py splits its terms.
SITE_HOST='tsg'\''wp00525'
SITE_DOMAIN='\.geaero'\''space\.net'
SITE_NAME='West Jeff'\''erson'
# Adjacent quoted strings CONCATENATE: 'ab''cd' is the four-character string
# abcd. The apostrophe-escape form ('ab'\''cd') looks almost identical and is
# not - it embeds a literal ' in the middle, so every pattern below matched
# nothing and this check silently passed everything from the day it was written
# until 2026-08-26. That is how a QA SaaS tenant sat in the shipped defaults for
# months with the gate green. Keep the fragments split (the scrub greps this
# file for the very strings the patterns are made of) and keep them ADJACENT.
SITE_HOST='tsg''wp00525'
SITE_DOMAIN='\.geaero''space\.net'
SITE_NAME='West Jeff''erson'
SITE_NETS='10\.134\.48\.|10\.48\.249\.'
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}"
# A named SaaS TENANT is as site-specific as a hostname, and this one shipped as
# the default ServiceNow URL for months: every adopting site would have linked
# its technicians into another organisation's QA instance. The vendor domain
# itself is fine to mention; the tenant is not.
SITE_TENANTS='geaero''spaceqa'
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}|${SITE_TENANTS}"
SITE_HITS=$(grep -rPni "$SITE_PATTERNS" "${EXCLUDES[@]}" \
--include='*.py' --include='*.vue' --include='*.js' \
--include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \
--include='*.json' --include='*.html' \
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \
| grep -v '/tests\?/' \
| grep -v '\.spec\.js:' \
| grep -v 'site_imports/' \
| grep -v 'installer/bundle/' \
| grep -v '\.plugins-staged/' \

View File

@@ -0,0 +1,136 @@
"""Remove credentials from backup revisions already stored in the database.
WHY THIS EXISTS
The NTLARS/DNC registry carries an FTP password in cleartext. The codec now
redacts credentials at PARSE, so nothing new reaches the database - but every
revision recorded before that still holds the password verbatim, where the
revision panel renders it and the download route hands it back as a
ready-to-import .reg. Anyone with backups.view could read it off a web page.
Rotating the password does not fix these rows: it makes them stale AND still
teaches anyone reading them what the old one was.
WHAT IT CHANGES: backuprevisions.payloadjson only, and only the values whose
NAME looks like a credential (the same test the codec applies). The value name
is kept and recorded in the revision's `redacted` list, so a restore from a
scrubbed revision says plainly which field must be set by hand rather than
quietly producing a machine that cannot log in.
WHAT IT DOES NOT CHANGE: contenthash. The hash identifies the CONFIGURATION a
bay reported, and dedup compares the next post against it. Rewriting hashes here
would make every bay's next post look like a change and insert a duplicate
revision across the fleet.
Usage (on the target instance, in the app dir):
venv\\Scripts\\python scripts\\redact_backup_secrets.py
venv\\Scripts\\python scripts\\redact_backup_secrets.py --commit
Dry run by default: it reports which revisions hold a credential and stops.
Take a database backup before --commit.
"""
import argparse
import json
import os
import sys
# Run from anywhere: put the repo root (parent of scripts/) on the path.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
# Load .env before anything reads config. Without it DATABASE_URL stays unset
# and config falls back to a hardcoded root@localhost URL, which fails with a
# bare 1045 Access denied naming a user the site does not use - a database
# credential error that is really "nothing told the app which database".
load_dotenv(os.path.join(os.getcwd(), '.env'))
from shopdb import create_app # noqa: E402
from shopdb.extensions import db # noqa: E402
def scrub(projection, issecretname, marker):
"""Redact credential values in one projection. Returns (changed, names)."""
if not isinstance(projection, dict):
return False, []
names = list(projection.get('redacted') or [])
changed = False
for key in projection.get('keys') or []:
if not isinstance(key, dict):
continue
path = key.get('path', '')
values = key.get('values')
if not isinstance(values, dict):
continue
for name, entry in values.items():
if not issecretname(name) or not isinstance(entry, dict):
continue
if entry.get('data') in ('', None, marker):
continue
entry['data'] = marker
fullname = '{}\\{}'.format(path, name) if path else name
if fullname not in names:
names.append(fullname)
changed = True
if changed:
projection['redacted'] = sorted(names)
return changed, names
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--commit', action='store_true',
help='Apply the changes. Dry run by default.')
args = parser.parse_args()
app = create_app()
with app.app_context():
from plugins.backups.models import BackupRevision
from plugins.backups.services.ntlars import issecretname, REDACTEDMARKER
rows = BackupRevision.query.order_by(BackupRevision.backuprevisionid).all()
hits = []
for revision in rows:
projection = revision.payload
if not projection:
continue
# Work on a copy so a dry run cannot mutate a loaded instance into
# something a later flush would write.
working = json.loads(json.dumps(projection))
changed, names = scrub(working, issecretname, REDACTEDMARKER)
if not changed:
continue
hits.append((revision, working, names))
if not hits:
print('No stored revision holds a credential. Nothing to do.')
return 0
print('{} revision(s) hold a credential:'.format(len(hits)))
for revision, _, names in hits:
print(' revision {:<6} asset {:<6} {:<10} {}'.format(
revision.backuprevisionid, revision.assetid,
revision.backupkind, ', '.join(names)))
if not args.commit:
print()
print('Dry run. Nothing was changed. Re-run with --commit to write.')
print('Take a database backup first.')
return 0
for revision, working, _ in hits:
revision.payload = working
db.session.commit()
print()
print('Redacted {} revision(s). contenthash left untouched, so no bay '
'will re-post as changed.'.format(len(hits)))
return 0
if __name__ == '__main__':
raise SystemExit(main())

View File

@@ -252,7 +252,7 @@ def register_cli_commands(app: Flask):
"""Register Flask CLI commands."""
from .plugins.cli import plugin_cli
from .cli import (db_cli, seed_cli, relationships_cli, csv_cli,
catalog_cli)
catalog_cli, audit_cli)
app.cli.add_command(plugin_cli)
app.cli.add_command(db_cli)
@@ -260,6 +260,7 @@ def register_cli_commands(app: Flask):
app.cli.add_command(relationships_cli)
app.cli.add_command(csv_cli)
app.cli.add_command(catalog_cli)
app.cli.add_command(audit_cli)
def register_error_handlers(app: Flask):

View File

@@ -1835,3 +1835,75 @@ def catalog_rename(table, source, target, apply_changes):
{'t': target, 'k': sourcerow[0]})
click.echo(click.style(f'Renamed {source!r} to {target!r}.', fg='green'))
@click.group('audit')
def audit_cli():
"""Audit log maintenance."""
pass
@audit_cli.command('stats')
@with_appcontext
def audit_stats():
"""Show how big the audit log is and what retention is configured."""
from shopdb.core.services import auditretention
days = auditretention.configureddays()
info = auditretention.stats(days or None)
click.echo(f"Entries: {info['total']:,}")
click.echo(f"Oldest: {info['oldest'] or '-'}")
click.echo(f"Newest: {info['newest'] or '-'}")
if days:
click.echo(f"Retention: {days} days")
click.echo(f"Expired: {info['expired']:,} entry(s) older than that")
else:
click.echo('Retention: not set - nothing is ever deleted')
click.echo("Set one in Settings > Site & Facility, or pass --days here.")
@audit_cli.command('purge')
@click.option('--days', type=int, default=None,
help='Override the configured retention for this run.')
@click.option('--apply', 'apply_changes', is_flag=True,
help='Actually delete. Without this the command only reports.')
@with_appcontext
def audit_purge(days, apply_changes):
"""Delete audit entries older than the retention window.
Reports by default and deletes only with --apply, because this is the one
command in the app that destroys history a site may be keeping deliberately.
"""
from shopdb.core.services import auditretention
days = days if days is not None else auditretention.configureddays()
if not days:
click.echo(click.style(
'No retention configured, so there is nothing to purge. Set '
'audit_retentiondays in Settings > Site & Facility, or pass --days.',
fg='yellow'))
return
preview = auditretention.purge(days, dryrun=True)
click.echo(f"Retention {days} days - cutoff {preview['cutoff']}")
click.echo(f"{preview['matched']:,} entry(s) are older than that.")
if not preview['matched']:
return
if not apply_changes:
click.echo(click.style(
'Dry run. Nothing was deleted. Re-run with --apply to write.',
fg='yellow'))
return
# No batch cap here: unlike the API this is not behind a proxy timeout, and
# a purge that stops halfway leaves the operator wondering which half.
result = auditretention.purge(days)
click.echo(click.style(
f"Deleted {result['deleted']:,} entry(s) in {result['batches']} batch(es).",
fg='green'))
if not result['complete']:
click.echo(click.style(
f"{result['remaining']:,} still older than the cutoff - re-run.",
fg='yellow'))

View File

@@ -4,7 +4,8 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.core.models import AuditLog
from shopdb.utils.responses import success_response
from shopdb.extensions import db
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
auditlogs_bp = Blueprint('auditlogs', __name__)
@@ -194,3 +195,74 @@ def db_func_count_by(column):
).group_by(column).all()
return {r[0]: r[1] for r in results}
@auditlogs_bp.route('/retention', methods=['GET'])
@jwt_required()
@require_permission('audit.view')
def get_retention():
"""Size of the log and what the configured retention would remove.
Drives the Site & Facility card: an admin should see the number of entries a
purge deletes BEFORE pressing anything.
"""
from shopdb.core.services import auditretention
days = auditretention.configureddays()
return success_response(auditretention.stats(days or None))
@auditlogs_bp.route('/purge', methods=['POST'])
@jwt_required()
@require_permission('audit.purge')
def purge_auditlogs():
"""Delete entries older than the retention window.
Separate permission from audit.view: reading the log and destroying it are
not the same act, and plenty of people need the first.
Batch-capped, because this runs inside a request that a proxy will time out.
An incomplete purge returns complete=false and the number still to go rather
than reporting a finished job - the caller re-posts, or runs
`flask audit purge --apply` on the server, which has no cap.
"""
from flask import request as flask_request
from shopdb.core.services import auditretention
body = flask_request.get_json(silent=True) or {}
days = body.get('days')
if days is None:
days = auditretention.configureddays()
try:
days = int(days)
except (TypeError, ValueError):
days = 0
if days <= 0:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'No retention period is configured. Set one in Site & Facility '
'settings before purging.')
dryrun = bool(body.get('dryrun'))
result = auditretention.purge(
days, dryrun=dryrun,
maxbatches=None if dryrun else auditretention.MAXBATCHES_REQUEST)
if dryrun:
message = '{:,} entry(s) would be deleted'.format(result['matched'])
elif result['complete']:
message = 'Deleted {:,} entry(s)'.format(result['deleted'])
else:
message = ('Deleted {:,} entry(s); {:,} still older than the cutoff. '
'Run it again to continue.').format(
result['deleted'], result['remaining'])
# The purge is itself an audited act, and this row postdates the cutoff so
# it survives the purge that created it.
if not dryrun and result['deleted']:
from shopdb.api import audit_log
audit_log(action='deleted', entitytype='AuditLog',
entityname='purge older than {} days'.format(days),
changes={'deleted': result['deleted'],
'cutoff': result['cutoff']})
return success_response(result, message=message)

View File

@@ -61,6 +61,32 @@ def _word_match(query, *columns, extra=None):
return db.and_(*[per_word(w) for w in words])
def _notefailure(domain, exc):
"""Record that one search domain failed this request, and log it.
Every domain below catches broadly and returns [] so one broken table cannot
take the whole search box down. That is right, but on its own it makes a
DOWN domain indistinguishable from an EMPTY one: the technician sees "no
results", is told they may have misspelled it, and concludes the record does
not exist. It does; the search just could not look.
The names collected here travel back in the response so the UI can say which
part of the search did not run.
"""
from flask import g
logger.error(f"{domain} search failed: {exc}")
failures = getattr(g, '_searchfailures', None)
if failures is None:
failures = g._searchfailures = []
if domain not in failures:
failures.append(domain)
def _searchfailures():
from flask import g
return list(getattr(g, '_searchfailures', []) or [])
def _require_enabled(name):
"""Raise ImportError when the named plugin is disabled.
@@ -72,13 +98,12 @@ def _require_enabled(name):
if pm and not pm.registry.is_enabled(name):
raise ImportError(f'{name} plugin disabled')
# Shipped GE defaults. Settings override these per-site; identical fallbacks
# live here so this consumer works even if the settings seed has not run.
SERVICENOW_URL_DEFAULT = (
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
# Fallbacks for when the settings seed has not run. The ServiceNow URL is BLANK
# on purpose and must stay that way: a hardcoded tenant here would link one
# site's technicians into another site's ServiceNow, and the code below already
# treats a blank URL as "ServiceNow not configured" and renders tickets as plain
# text. There is no sensible cross-site default for a tenant URL.
SERVICENOW_URL_DEFAULT = ''
SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT'
EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$'
@@ -108,9 +133,12 @@ def _get_search_integrations():
prefixes_raw = SERVICENOW_PREFIXES_DEFAULT
prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()]
# Missing means unconfigured, and unconfigured means OFF - matching the
# shipped default. Defaulting to True here made a site with no ServiceNow
# settings at all behave as though the integration were on.
servicenow_enabled = settings.get('servicenow_enabled')
if servicenow_enabled is None:
servicenow_enabled = True
servicenow_enabled = False
servicenow_url = settings.get('servicenow_search_url')
if servicenow_url is None:
@@ -251,7 +279,66 @@ def _search_applications(query, search_term):
'relevance': relevance
})
except Exception as e:
logger.error(f"Application search failed: {e}")
_notefailure("Application", e)
return results
def _search_guides(query, search_term):
"""Search guides by title, summary, tags and BODY.
The body matters more here than in most domains. People look for a procedure
by something written halfway down it - "the one about the drive letter" -
not by a title they never read. The body is a JSON blob of blocks, so this
is a LIKE against it: crude, but it finds the guide, and a guide corpus is
tens of documents rather than millions of rows.
PUBLISHED ONLY, and published TEXT only. A draft is deliberately invisible
to readers, and search is the one place that rule would otherwise leak -
somebody would find a half-written procedure through the search box and
follow it.
Restricting to published GUIDES is not enough on its own, which is how this
first shipped: title/summary/tags/blocksjson on the Guide row are the
WORKING copy. A published guide with unpublished edits would match on a word
that exists only in the draft, and hand the reader a hit that is not in the
page when they open it. So the match, the title and the summary all come
from the published revision.
"""
results = []
try:
_require_enabled('guides')
from plugins.guides.models import Guide, GuideRevision
published = db.aliased(GuideRevision)
rows = db.session.query(Guide, published).join(
published, Guide.publishedrevisionid == published.guiderevisionid
).filter(
Guide.isactive == True,
_word_match(query, published.title, published.summary,
published.tags, published.blocksjson)
).order_by(Guide.modifieddate.desc()).limit(20).all()
for guide, revision in rows:
# A title match outranks a body match: someone searching "marker"
# wants the guide called that, not every guide mentioning one.
relevance = 10
if query.lower() in (revision.title or '').lower():
relevance += 20
if revision.tags and query.lower() in revision.tags.lower():
relevance += 15
results.append({
'type': 'guide',
'id': guide.guideid,
'title': revision.title,
'subtitle': revision.summary,
'url': f"/guides/{guide.slug}",
'relevance': relevance
})
except ImportError:
pass # guides plugin absent or disabled
except Exception as e:
_notefailure("Guides", e)
return results
@@ -314,7 +401,7 @@ def _search_knowledgebase(query, search_term):
except ImportError:
pass # knowledgebase plugin absent or disabled
except Exception as e:
logger.error(f"KnowledgeBase search failed: {e}")
_notefailure("KnowledgeBase", e)
return results
@@ -384,7 +471,7 @@ def _search_employees(query, search_term):
'relevance': relevance
})
except Exception as e:
logger.error(f"Employee search failed: {e}")
_notefailure("Employee", e)
return results
@@ -434,7 +521,7 @@ def _search_assets(query, search_term):
results.append(_get_asset_result(asset, query, relevance))
except Exception as e:
logger.error(f"Asset search failed: {e}")
_notefailure("Asset", e)
return results
@@ -474,7 +561,7 @@ def _search_measuringtools(query, search_term):
except ImportError:
pass # measuringtools plugin absent or disabled
except Exception as e:
logger.error(f"Measuring tool search failed: {e}")
_notefailure("Measuring tool", e)
return results
@@ -525,7 +612,7 @@ def _search_usbdevices(query, search_term):
except ImportError:
pass # usb plugin absent or disabled
except Exception as e:
logger.error(f"USB device search failed: {e}")
_notefailure("USB device", e)
return results
@@ -574,7 +661,7 @@ def _search_printeditems(query, search_term):
except ImportError:
pass # printedparts plugin absent or disabled
except Exception as e:
logger.error(f"Printed item search failed: {e}")
_notefailure("Printed item", e)
return results
@@ -609,7 +696,7 @@ def _search_customfields(query, search_term):
result['subtitle'] = field.label
results.append(result)
except Exception as e:
logger.error(f"Custom field search failed: {e}")
_notefailure("Custom field", e)
return results
@@ -637,7 +724,7 @@ def _search_by_ip(query, search_term):
result['subtitle'] = comm.ipaddress
results.append(result)
except Exception as e:
logger.error(f"IP search failed: {e}")
_notefailure("IP", e)
return results
@@ -669,7 +756,7 @@ def _search_subnets(query):
except ImportError:
pass
except Exception as e:
logger.error(f"Subnet search failed: {e}")
_notefailure("Subnet", e)
return results
@@ -697,7 +784,7 @@ def _search_hostnames(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Computer hostname search failed: {e}")
_notefailure("Computer hostname", e)
# Search Printers
try:
@@ -721,7 +808,7 @@ def _search_hostnames(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Printer hostname search failed: {e}")
_notefailure("Printer hostname", e)
# Search Network Devices
try:
@@ -744,7 +831,7 @@ def _search_hostnames(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Network device hostname search failed: {e}")
_notefailure("Network device hostname", e)
return results
@@ -792,7 +879,7 @@ def _search_notifications(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Notification search failed: {e}")
_notefailure("Notification", e)
return results
@@ -826,7 +913,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Machine vendor/model/type search failed: {e}")
_notefailure("Machine vendor/model/type", e)
# Printers: vendor, model, printertype
try:
@@ -854,7 +941,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Printer vendor/model/type search failed: {e}")
_notefailure("Printer vendor/model/type", e)
# Network Devices: vendor, networkdevicetype
try:
@@ -880,7 +967,7 @@ def _search_vendor_model_type(query, search_term):
except ImportError:
pass
except Exception as e:
logger.error(f"Network device vendor/type search failed: {e}")
_notefailure("Network device vendor/type", e)
return results
@@ -1148,6 +1235,7 @@ def global_search():
# Run all search domains
results.extend(_search_applications(query, search_term))
results.extend(_search_knowledgebase(query, search_term))
results.extend(_search_guides(query, search_term))
results.extend(_search_employees(query, search_term))
results.extend(_search_assets(query, search_term))
results.extend(_search_measuringtools(query, search_term))
@@ -1200,12 +1288,24 @@ def global_search():
# multi-word and cross-field matching changes may already have fixed a good
# share of what used to fail. Guessing at that would be building for a
# problem nobody has measured.
degraded = _searchfailures()
suggestion = None
if not unique_results:
logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge.
#
# Still offered on a degraded run, because the response also carries
# `degraded` and the UI states it ABOVE the suggestion - the reader is
# told the search was incomplete before being offered a spelling.
#
# Suppressing it instead was tried and is worse: a domain that is
# chronically unreachable (an external HR directory, say) would silently
# disable spelling help for every search, for ever, and nothing would
# say why. Naming the failure is the honest fix; withholding the
# suggestion just removes a second useful signal.
suggestion = _suggestion_for(query)
# Limit total results
@@ -1221,6 +1321,10 @@ def global_search():
}
if suggestion:
response_data['suggestion'] = suggestion
if degraded:
# The UI says which part of the search did not run. Without this a
# broken domain reads as "that record does not exist".
response_data['degraded'] = degraded
redirect = _check_smart_redirect(query, classification)
if redirect:

View File

@@ -103,6 +103,7 @@ IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device',
SEARCH_DOMAINS = {
'application': 'Applications',
'knowledgebase': 'Knowledge Base',
'guide': 'Guides',
'employee': 'Employees',
'machine': 'Machines',
'computer': 'PCs',
@@ -820,6 +821,13 @@ def build_default_settings():
'category': 'site',
'description': 'Public base URL of this site INCLUDING any subpath it is served under, e.g. https://shopdb.example.net or https://host/shopdb. Consumers append the in-app route to this and add nothing themselves, so a subpath left off here produces links that 404. Blank = use the browsing origin.'
},
{
'key': 'audit_retentiondays',
'value': '0',
'valuetype': 'integer',
'category': 'site',
'description': 'Delete audit log entries older than this many days. 0 (default) keeps everything forever. The audit log is append-only and grows for as long as the site runs, so a long-lived site eventually needs a limit here; nothing is deleted until one is set, and the settings page shows how many entries a purge would remove before it runs.'
},
{
'key': 'facility_name',
'value': '',
@@ -1039,20 +1047,22 @@ def build_default_settings():
defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [
# ServiceNow ticket links. Each instance points these at its own
# ServiceNow tenant; blank/disabled renders tickets as plain text.
#
# Shipped OFF and BLANK, like the Zabbix block below. These defaults
# used to be one tenant's QA instance, which is wrong twice over: a
# sister site adopting this product would silently link its technicians
# at somebody else's ServiceNow, and even at the originating site a QA
# tenant is not where a real ticket lives.
{
'key': 'servicenow_enabled',
'value': 'true',
'value': 'false',
'valuetype': 'boolean',
'category': 'integrations',
'description': 'Enable ServiceNow ticket recognition and links in search and dashboards'
},
{
'key': 'servicenow_search_url',
'value': (
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
),
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.'
@@ -1066,14 +1076,14 @@ def build_default_settings():
},
{
'key': 'servicenow_incident_url',
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'ServiceNow incident URL template. {ticket} is the incident number.'
},
{
'key': 'servicenow_change_url',
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
'value': '',
'valuetype': 'string',
'category': 'integrations',
'description': 'ServiceNow change-request URL template. {ticket} is the change number.'

View File

@@ -62,6 +62,9 @@ class Permission(db.Model):
('users.delete', 'Delete users', 'admin'),
# Audit
('audit.view', 'View audit logs', 'admin'),
# Deliberately separate from audit.view: reading the log and destroying
# history are different acts, and many people need only the first.
('audit.purge', 'Delete audit log entries past the retention period', 'admin'),
# API tokens
('apitokens.create', 'Create and manage API tokens', 'apitokens'),
# Collector service tokens. A token scoped to ONLY this permission is a

View File

@@ -0,0 +1,133 @@
"""Audit log retention.
The audit log is append-only and nothing ever removed a row, so it grows for as
long as a site runs. That is fine for a year and not fine forever: this site
reached the point where the log is most of the database and the audit page is
tens of thousands of pages deep - which also makes it useless, because nobody
pages through that to find anything.
Retention is OPT-IN, and the default keeps everything. An upgrade that silently
started deleting a site's audit history would be indefensible; a site that wants
a limit sets one, and is told exactly how many rows that will remove before it
happens.
DELETING IN CHUNKS IS NOT AN OPTIMISATION. A single
`DELETE FROM auditlogs WHERE timestamp < ...` over a million rows builds one
enormous transaction, holds locks across the table for the duration, and on a
modest MySQL box either times out or blocks every writer while it runs - which
on this server means the collectors that are posting inventory. Deleting in
bounded batches, committing each, keeps every individual statement short.
Chunking is done by SELECTing a page of ids and deleting those. `DELETE ... LIMIT`
would be simpler but is MySQL-only, and the test suite runs on SQLite.
"""
from datetime import datetime, timedelta, timezone
from shopdb.extensions import db
from shopdb.core.models import AuditLog
# Big enough that a large purge does not take thousands of round trips, small
# enough that one statement stays short and a kill mid-purge loses little work.
CHUNKSIZE = 5000
# A purge triggered from the browser must not run until a proxy gives up on it.
# The CLI has no such limit and passes None.
MAXBATCHES_REQUEST = 40
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def cutofffor(days):
"""The timestamp before which rows are older than the retention window."""
return _utcnow() - timedelta(days=int(days))
def stats(days=None):
"""Row count, date range, and how many rows a purge at `days` would remove.
Cheap enough to render on a settings page: three aggregates over an indexed
column.
"""
total = db.session.query(db.func.count(AuditLog.auditlogid)).scalar() or 0
oldest, newest = db.session.query(
db.func.min(AuditLog.timestamp), db.func.max(AuditLog.timestamp)).first()
expired = None
if days:
expired = (db.session.query(db.func.count(AuditLog.auditlogid))
.filter(AuditLog.timestamp < cutofffor(days)).scalar() or 0)
return {
'total': total,
'oldest': oldest.isoformat() if oldest else None,
'newest': newest.isoformat() if newest else None,
'retentiondays': int(days) if days else 0,
'expired': expired,
}
def purge(days, dryrun=False, maxbatches=None, chunksize=CHUNKSIZE):
"""Delete audit rows older than `days`, in committed batches.
Returns a dict describing what happened, including `remaining`: a purge that
stopped early because it hit `maxbatches` says so rather than reporting
success over a job it did not finish. A caller that reported "purged" while
900k rows were still there would be exactly the kind of quiet half-failure
this codebase keeps getting bitten by.
"""
days = int(days or 0)
if days <= 0:
raise ValueError('retention must be a positive number of days; '
'0 means keep everything, which is not a purge')
cutoff = cutofffor(days)
matched = (db.session.query(db.func.count(AuditLog.auditlogid))
.filter(AuditLog.timestamp < cutoff).scalar() or 0)
if dryrun or not matched:
return {'cutoff': cutoff.isoformat(), 'matched': matched, 'deleted': 0,
'remaining': matched, 'batches': 0, 'dryrun': bool(dryrun),
'complete': not matched}
deleted = 0
batches = 0
while True:
if maxbatches is not None and batches >= maxbatches:
break
ids = [row[0] for row in
db.session.query(AuditLog.auditlogid)
.filter(AuditLog.timestamp < cutoff)
.order_by(AuditLog.auditlogid)
.limit(chunksize).all()]
if not ids:
break
(db.session.query(AuditLog)
.filter(AuditLog.auditlogid.in_(ids))
.delete(synchronize_session=False))
db.session.commit()
deleted += len(ids)
batches += 1
remaining = (db.session.query(db.func.count(AuditLog.auditlogid))
.filter(AuditLog.timestamp < cutoff).scalar() or 0)
return {'cutoff': cutoff.isoformat(), 'matched': matched, 'deleted': deleted,
'remaining': remaining, 'batches': batches, 'dryrun': False,
'complete': remaining == 0}
def configureddays():
"""The site's retention setting, or 0 when nothing is set (keep forever)."""
from shopdb.core.models import Setting
try:
raw = Setting.get('audit_retentiondays')
except Exception:
return 0
try:
return max(0, int(raw or 0))
except (TypeError, ValueError):
return 0

View File

@@ -59,6 +59,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'manifestblobs',
'manifestenforcementreports', 'manifestenforcementresults',
'pctypealiases'),
'guides': ('guides', 'guiderevisions', 'guideimages'),
'knowledgebase': ('knowledgebase',),
'machines': ('machinetypes', 'machines'),
'measuringtools': ('measuringtooltypes', 'measuringtools'),

View File

@@ -0,0 +1,135 @@
"""Audit retention: the purge, its default, and its refusal to overreach.
The audit log is the one table in this app where deleting is the FEATURE, which
makes every guard here load-bearing. A retention bug does not corrupt data, it
destroys history nobody can get back.
"""
from datetime import datetime, timedelta, timezone
import pytest
from shopdb.core.models import AuditLog
from shopdb.core.services import auditretention
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _entry(db, ageindays, action='updated'):
row = AuditLog(action=action, entitytype='Machine', entityname='M-1',
timestamp=_utcnow() - timedelta(days=ageindays))
db.session.add(row)
db.session.commit()
return row
def test_nothing_is_deleted_when_no_retention_is_configured(db):
"""The default must keep everything.
An upgrade that started silently deleting a site's audit history because a
setting defaulted to 90 days would be indefensible.
"""
_entry(db, 4000)
assert auditretention.configureddays() == 0
with pytest.raises(ValueError):
auditretention.purge(0)
assert AuditLog.query.count() == 1
def test_a_purge_deletes_only_what_is_older_than_the_window(db):
_entry(db, 400)
_entry(db, 100)
keeper = _entry(db, 5)
result = auditretention.purge(90)
assert result['deleted'] == 2
assert result['complete'] is True
assert [row.auditlogid for row in AuditLog.query.all()] == [keeper.auditlogid]
def test_a_dry_run_reports_without_deleting(db):
_entry(db, 400)
_entry(db, 400)
result = auditretention.purge(90, dryrun=True)
assert result['matched'] == 2
assert result['deleted'] == 0
assert AuditLog.query.count() == 2, 'a dry run that deleted would be a trap'
def test_a_capped_purge_says_it_did_not_finish(db):
"""A half-done purge must not report success.
The API caps batches so the request cannot outlive a proxy timeout. If that
path reported a finished job, an admin would believe a log was trimmed while
most of it was still there.
"""
for _ in range(5):
_entry(db, 400)
result = auditretention.purge(90, maxbatches=2, chunksize=1)
assert result['deleted'] == 2
assert result['remaining'] == 3
assert result['complete'] is False
def test_chunking_deletes_everything_it_matched(db):
"""Batching is an implementation detail; the outcome must not depend on it."""
for _ in range(7):
_entry(db, 400)
result = auditretention.purge(90, chunksize=2)
assert result['deleted'] == 7
assert result['complete'] is True
assert AuditLog.query.count() == 0
def test_stats_report_the_backlog_a_purge_would_remove(db):
_entry(db, 400)
_entry(db, 5)
info = auditretention.stats(90)
assert info['total'] == 2
assert info['expired'] == 1
assert info['oldest'] is not None
def test_purging_requires_its_own_permission(client, db, auth_headers):
"""audit.view is not permission to destroy the log."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, Role, Permission
perm = Permission(name='audit.view', description='View audit logs',
category='admin')
role = Role(rolename='auditreader', description='Reads the audit log')
role.permissions.append(perm)
db.session.add_all([perm, role])
db.session.flush()
user = User(username='auditreader', email='ar@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
login = client.post('/api/auth/login',
json={'username': 'auditreader', 'password': 'testpass'})
headers = {'Authorization':
'Bearer ' + login.get_json()['data']['access_token']}
assert client.get('/api/auditlogs/retention',
headers=headers).status_code == 200
assert client.post('/api/auditlogs/purge', json={'days': 90},
headers=headers).status_code == 403
def test_the_purge_route_refuses_when_no_retention_is_set(client, db, auth_headers):
response = client.post('/api/auditlogs/purge', json={}, headers=auth_headers)
assert response.status_code == 400
assert 'retention' in response.get_json()['data']['error']['message'].lower()

View File

@@ -266,3 +266,75 @@ def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow):
assert len(rows) == 1
assert rows[0]['id'] == subnetrow
assert rows[0]['url'] == f'/networks/{subnetrow}'
# ---------------------------------------------------------------------------
# Guides. A guide is not an asset either, so the generic asset search cannot see
# one - and the draft rule makes the omission worse than invisibility: a
# half-written procedure reachable through the search box is one somebody
# follows.
# ---------------------------------------------------------------------------
@pytest.fixture
def guides(db):
"""One published guide and one draft, with distinctive text.
The published one carries its distinguishing words in the BODY, not the
title, because that is how people look for a procedure - by something
written halfway down it.
"""
from plugins.guides.models import Guide
from plugins.guides.services import authoring
published = Guide(title='Zzmarker controller swap', slug='zzmarker-swap',
status='draft')
draft = Guide(title='Zzdraft unfinished procedure', slug='zzdraft-unfinished',
status='draft')
db.session.add_all([published, draft])
db.session.flush()
authoring.save(published, title=published.title,
summary='Restoring after a swap', tags='zztelesis, zzbackups',
blocks=[{'id': 'g1', 'type': 'paragraph',
'text': 'Check the zzmappeddrive before starting.'}])
authoring.publish(published)
authoring.save(draft, title=draft.title, summary='', tags='zztelesis',
blocks=[{'id': 'g2', 'type': 'paragraph',
'text': 'Half written zzmappeddrive notes.'}])
db.session.commit()
return {'published': published.guideid, 'draft': draft.guideid}
def test_a_published_guide_is_found_by_title(client, guides):
rows = [r for r in search(client, 'Zzmarker') if r['type'] == 'guide']
assert len(rows) == 1
assert rows[0]['id'] == guides['published']
assert rows[0]['url'] == '/guides/zzmarker-swap'
def test_a_guide_is_found_by_tag(client, guides):
"""Tags are how a technician narrows to a family of procedures."""
assert 'Zzmarker controller swap' in titles(client, 'zzbackups', 'guide')
def test_a_guide_is_found_by_words_in_its_body(client, guides):
"""People search for what they remember reading, not for the title.
The body is stored as a JSON blob of blocks, so this is the check that the
blob is actually reachable rather than merely present.
"""
assert 'Zzmarker controller swap' in titles(client, 'zzmappeddrive', 'guide')
def test_a_draft_guide_is_never_returned(client, guides):
"""The draft rule has to hold on EVERY path that reaches the row.
A draft is deliberately invisible to readers, and search is where that rule
would otherwise leak: somebody finds a half-written procedure through the
search box and follows it. The draft here shares a tag and a body word with
the published guide, so it would ride along on both queries if the filter
were missing.
"""
assert 'Zzdraft unfinished procedure' not in titles(client, 'zzmappeddrive', 'guide')
assert 'Zzdraft unfinished procedure' not in titles(client, 'zztelesis', 'guide')
assert titles(client, 'Zzdraft', 'guide') == []

View File

@@ -0,0 +1,65 @@
"""A search domain that FAILS must not look like a domain that found nothing.
Every domain catches broadly and returns [] so one broken table cannot take the
whole search box down. Correct - but on its own it makes a down domain
indistinguishable from an empty one, and the technician is then told they may
have misspelled a query that was right.
The failure is injected INSIDE a domain (by breaking something it uses) rather
than by replacing the domain function, because replacing the function would also
remove the try/except being tested.
"""
import pytest
class _ExplodingModel:
"""Stands in for a model whose table is unreachable."""
@property
def query(self):
raise RuntimeError('table is gone')
def __getattr__(self, name):
raise RuntimeError('table is gone')
@pytest.fixture
def broken_applications(monkeypatch):
from shopdb.core.api import search as searchmod
monkeypatch.setattr(searchmod, 'Application', _ExplodingModel())
return searchmod
def test_a_failing_domain_is_reported_as_degraded(client, db, auth_headers,
broken_applications):
response = client.get('/api/search?q=nothingmatchesthis', headers=auth_headers)
assert response.status_code == 200, 'one broken domain must not 500 the search'
data = response.get_json()['data']
assert 'degraded' in data, 'a failed domain must be visible to the caller'
assert 'Application' in data['degraded']
def test_a_healthy_domain_is_not_listed_as_degraded(client, db, auth_headers,
broken_applications):
response = client.get('/api/search?q=nothingmatchesthis', headers=auth_headers)
degraded = response.get_json()['data'].get('degraded', [])
assert 'Asset' not in degraded, 'only the domain that actually failed'
def test_a_degraded_run_still_offers_a_spelling(client, db, auth_headers,
broken_applications):
"""Both signals, not one.
Suppressing the suggestion on a degraded run was tried first. It is worse: a
chronically unreachable domain - an external HR directory, say - would
silently disable spelling help for every search for ever, with nothing
saying why. The banner states the search was incomplete, and the suggestion
stays available above it.
"""
response = client.get('/api/search?q=machne', headers=auth_headers)
data = response.get_json()['data']
assert data.get('degraded'), 'the failure is still named'
# The suggestion is offered when spellfix has one; the point is that being
# degraded does not by itself remove it.
assert 'degraded' in data

View File

@@ -14,14 +14,41 @@ from shopdb.core.api.settings import invalidate_settings_cache
from shopdb.core.api.search import _get_search_integrations, _classify_query
def _configure_servicenow():
"""Turn ServiceNow on with a tenant URL.
Required now, and that is the point: the integration ships OFF with a BLANK
URL, because the defaults used to be one organisation's QA tenant and any
adopting site inherited links into somebody else's ServiceNow.
"""
Setting.set('servicenow_enabled', True, valuetype='boolean',
category='integrations')
Setting.set('servicenow_search_url',
'https://example.service-now.com/search/{ticket}',
valuetype='string', category='integrations')
def _redirect(client, auth_headers, term):
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data'].get('redirect')
def test_unconfigured_servicenow_yields_no_redirect(client, db, auth_headers):
"""Shipped OFF and BLANK. Unconfigured must mean inactive.
The defaults were once a QA ServiceNow tenant with enabled=true, so a site
that had never configured ServiceNow still sent its technicians to a tenant
they could not reach - and an adopting site to another organisation's.
"""
invalidate_settings_cache()
redirect = _redirect(client, auth_headers, 'GEINC123')
assert redirect is None or redirect.get('type') != 'servicenow'
def test_default_prefix_classifies_servicenow(client, db, auth_headers):
"""With no override, a GE-prefixed ticket redirects to ServiceNow."""
"""Once configured, a default-prefixed ticket redirects to ServiceNow."""
_configure_servicenow()
invalidate_settings_cache()
redirect = _redirect(client, auth_headers, 'GEINC123')
assert redirect is not None
@@ -30,6 +57,7 @@ def test_default_prefix_classifies_servicenow(client, db, auth_headers):
def test_custom_prefix_classifies_servicenow(client, db, auth_headers):
"""A site-configured prefix classifies its tickets as ServiceNow."""
_configure_servicenow()
Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string',
category='integrations')
invalidate_settings_cache()

View File

@@ -143,12 +143,19 @@ def test_defaults_contain_new_branding_keys():
def test_defaults_contain_new_integration_keys():
"""build_default_settings seeds the ServiceNow integration keys."""
by_key = _defaults_by_key()
assert by_key['servicenow_enabled']['value'] == 'true'
# Ships OFF with blank URLs: the defaults were once a QA tenant, which an
# adopting site would have inherited as links into another organisation.
assert by_key['servicenow_enabled']['value'] == 'false'
assert by_key['servicenow_enabled']['category'] == 'integrations'
assert by_key['servicenow_ticket_prefixes']['value'] == 'GEINC,GECHG,GERIT,GESCT'
assert '{ticket}' in by_key['servicenow_search_url']['value']
assert '{ticket}' in by_key['servicenow_incident_url']['value']
assert '{ticket}' in by_key['servicenow_change_url']['value']
# Blank, not a tenant URL. There is no cross-site default for "which
# ServiceNow instance is yours", and a blank URL is what the search code
# reads as "not configured" - tickets then render as plain text.
for key in ('servicenow_search_url', 'servicenow_incident_url',
'servicenow_change_url'):
assert by_key[key]['value'] == '', key
assert '{ticket}' in by_key[key]['description'], \
'the placeholder must still be documented for whoever fills it in'
def test_defaults_contain_new_site_keys():

View File

@@ -54,6 +54,9 @@ EXPECTED_HEAD_REVISION['computers'] = 'computers0003azurefields'
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
# otherwise throws away.
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
# guides is a NEW plugin, so its 0001 builds tables rather than stamping an
# anchor over core-owned ones.
EXPECTED_HEAD_REVISION['guides'] = 'guides0001baseline'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
@@ -283,18 +286,21 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
# Plugins whose whole chain is a single baseline. Their tables existing already
# is the ONLY divergence possible between schema and stamp, so upgrade-all must
# handle it end to end. A longer chain can also meet an ALTER whose column is
# already present, which no amount of guarding a create_table fixes - see the
# test below for why that is a different problem.
SINGLE_BASELINE_PLUGINS = tuple(
plugin for plugin in POST_CUTOVER_PLUGINS
if len(list((PLUGINS_DIR / plugin / 'migrations' / 'versions').glob('0*.py'))) == 1
)
# EVERY post-cutover chain, not just the single-baseline ones. This was once
# restricted to plugins whose whole chain is one baseline, on the reasoning that
# a longer chain meets a second problem - an ALTER whose column is already
# present - that guarding a create_table does not fix. True, but the conclusion
# was wrong: the answer is to guard the ALTERs too, not to stop testing the
# chains that have them. While this was narrowed, printedparts 0003 sat
# unguarded for a month and would fail upgrade-all on any create-all-built
# database, taking 0004 down with it, with no test able to see it.
#
# The invariant is now the whole one: a post-cutover chain must run to its head
# against a database whose tables were built from the models and stamped
# nowhere, and must do it twice.
@pytest.mark.parametrize('plugin', SINGLE_BASELINE_PLUGINS)
@pytest.mark.parametrize('plugin', POST_CUTOVER_PLUGINS)
def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch):
"""A baseline must stamp, not fail, when its table is already there.

View File

@@ -1416,3 +1416,112 @@ def test_the_card_omits_the_snapshot_row_when_no_path_is_passed(bk_app, bk_plugi
from plugins.backups.services import partmarkerinfo
card = partmarkerinfo.build(_markermanifest(['A.PAT']), 1)
assert 'Snapshot' not in [f['label'] for f in card['fields']]
def test_an_empty_source_hostname_still_dedups(bk_app, bk_plugin):
"""A client posting sourcehostname='' must not defeat dedup forever.
samesource() maps '' to IS NULL for the QUERY, but the insert stored the raw
'' - so the rows a client wrote could never match its own next post. Every
post inserted, the table grew without bound, and the revision history filled
with identical entries nobody could read.
"""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
first = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
assert first['action'] == 'created'
second = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
assert second['action'] == 'noop', 'an identical re-post must dedup'
rows = BackupRevision.query.all()
assert len(rows) == 1
assert rows[0].sourcehostname is None, \
"'' is stored as NULL, matching how samesource() queries it"
def test_a_padded_source_hostname_is_the_same_source(bk_app, bk_plugin):
"""Whitespace must not fork a machine's history into two chains."""
with bk_app.app_context():
bk_plugin.apply_collector_payload(_payload(sourcehostname='GGBX0NH3ESF'))
second = bk_plugin.apply_collector_payload(
_payload(sourcehostname=' GGBX0NH3ESF '))
assert second['action'] == 'noop'
# ---------------------------------------------------------------------------
# Credential redaction. The DNC registry carries an FTP password in cleartext;
# stored verbatim it reached the database, the revision panel and the download
# route, so anyone with backups.view could read the fleet's password off a page.
# ---------------------------------------------------------------------------
# A configured install that also carries the FTP credential, which is what a
# real marker/DNC bay actually has in its registry.
SECRETREG = CONFIGUREDREG.replace(
'"MachineNo"="3204"',
'"MachineNo"="3204"\r\n"FtpPasswd"="hunter2"\r\n"FtpUser"="dnc"')
def test_a_credential_never_enters_the_projection():
"""Redaction happens at parse, so the secret is never persisted.
Redacting on display instead would leave it at rest and one query away.
"""
import json
from plugins.backups.services import ntlars
projection = ntlars.parse(_asbytes(SECRETREG))
assert 'hunter2' not in json.dumps(projection)
assert projection['redacted'] == ['General\\FtpPasswd']
values = dict(projection['keys'][0]['values'])
for key in projection['keys']:
values.update(key['values'])
assert values['FtpPasswd']['data'] == ntlars.REDACTEDMARKER
assert values['FtpUser']['data'] == 'dnc', 'only credentials are withheld'
def test_a_redacted_value_is_commented_out_not_imported():
"""Importing the marker as a password yields a machine that cannot log in.
It would LOOK restored, and the technician would go debugging the network.
A commented-out value fails at the one moment somebody is reading the file.
"""
from plugins.backups.services import ntlars
text = ntlars.render(ntlars.parse(_asbytes(SECRETREG))).decode('utf-16-le')
assert 'hunter2' not in text
assert ntlars.REDACTEDMARKER not in text
assert 'FtpPasswd' in text, 'the omission must be named, not silent'
assert '"FtpPasswd"="' not in text, 'must not be an importable value line'
assert '"FtpUser"="dnc"' in text, 'everything else still restores'
def test_the_download_route_cannot_hand_back_a_password(bk_app, bk_plugin):
"""End to end: post a config with a password, download it back."""
with bk_app.app_context():
result = bk_plugin.apply_collector_payload(_payload(reg=SECRETREG))
assert result['action'] == 'created'
from plugins.backups.models import BackupRevision
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
stored = json.dumps(revision.payload)
assert 'hunter2' not in stored
rendered = ntlars_render(revision.payload)
assert 'hunter2' not in rendered
def ntlars_render(projection):
from plugins.backups.services import ntlars
return ntlars.render(projection).decode('utf-16-le')
def test_secret_name_matching_covers_the_usual_spellings():
from plugins.backups.services.ntlars import issecretname
for name in ('FtpPasswd', 'Password', 'ftp_pwd', 'ApiKey', 'AuthToken',
'ClientSecret'):
assert issecretname(name), name
for name in ('FtpUser', 'FMSHost', 'Retries', 'Passive'):
assert not issecretname(name), name

View File

@@ -0,0 +1,561 @@
"""Guides: block validation, revisioning, and the draft/publish split.
The behaviour worth protecting hardest is the last one. A technician following a
procedure on a shop floor must not have it change under them because somebody is
editing it upstairs, and the only thing standing between them and that is the
published pointer.
"""
import pytest
from shopdb import create_app
from shopdb.extensions import db as _db
from shopdb.plugins import plugin_manager
# ---------------------------------------------------------------------------
# Block validation - pure, no app context needed. These rules exist to stop a
# BAD SAVE becoming a guide that will not render weeks later, when the author
# has moved on and the only evidence is a blank page.
# ---------------------------------------------------------------------------
def _block(blockid='b1', blocktype='paragraph', **extra):
return dict({'id': blockid, 'type': blocktype}, **extra)
def test_a_well_formed_block_list_passes():
from plugins.guides.services.blocks import validate
blocks = [
_block('b1', 'heading', text='Before you start'),
_block('b2', 'paragraph', text='Check the **cable** first.'),
_block('b3', 'checklist', items=[{'id': 'i1', 'text': 'Power on'}]),
]
assert validate(blocks) == blocks
def test_a_block_without_an_id_is_refused():
"""Ids are what make a tick rememberable.
Without one the checkbox silently loses its state on the next visit, which
reads to a user as "the checkboxes do not work" - a bug report with no
stack trace and no obvious cause.
"""
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([{'type': 'paragraph', 'text': 'no id'}])
assert 'stable id' in str(exc.value)
def test_duplicate_ids_are_refused():
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('same'), _block('same')])
assert 'duplicate' in str(exc.value)
def test_an_unknown_block_type_is_refused_and_lists_the_known_ones():
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('b1', 'carousel')])
message = str(exc.value)
assert 'carousel' in message and 'checklist' in message
def test_checklist_items_need_their_own_ids():
"""Same reason as blocks: the item id is what a tick is stored against."""
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('b1', 'checklist', items=[{'text': 'no id'}])])
assert 'item' in str(exc.value)
def test_the_error_names_which_block_is_wrong():
"""An author fixing a rejected save should not have to count blocks."""
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('b1'), _block('b2'), {'type': 'paragraph'}])
assert str(exc.value).startswith('block 3:')
def test_content_hash_covers_title_and_tags_not_just_blocks():
"""Renaming or retagging a guide is a change a reader notices.
Hashing only the body would drop those edits out of the history entirely.
"""
from plugins.guides.services.blocks import contenthash
blocks = [_block('b1', 'paragraph', text='same body')]
base = contenthash('Title', 'Summary', 'pxe, share', blocks)
assert contenthash('Different', 'Summary', 'pxe, share', blocks) != base
assert contenthash('Title', 'Summary', 'pxe', blocks) != base
assert contenthash('Title', 'Different', 'pxe, share', blocks) != base
def test_content_hash_ignores_tag_order_case_and_spacing():
"""Otherwise reordering tags in the editor would manufacture a version."""
from plugins.guides.services.blocks import contenthash
blocks = [_block()]
assert (contenthash('T', '', 'PXE, Share', blocks)
== contenthash('T', '', 'share ,pxe', blocks))
def test_checklist_item_count_counts_only_checklists():
from plugins.guides.services.blocks import checklistitemcount
blocks = [
_block('b1', 'checklist', items=[{'id': 'i1'}, {'id': 'i2'}]),
_block('b2', 'bullets', items=['not tickable']),
_block('b3', 'checklist', items=[{'id': 'i3'}]),
]
assert checklistitemcount(blocks) == 3
def test_plaintext_flattens_a_guide_for_search():
"""People search for something written halfway down, not for the title."""
from plugins.guides.services.blocks import plaintext
blocks = [
_block('b1', 'heading', text='Mapped drive'),
_block('b2', 'checklist', items=[{'id': 'i1', 'text': 'Run the sync'}]),
_block('b3', 'table', rows=[['left', 'right']]),
]
flat = plaintext(blocks)
assert 'Mapped drive' in flat and 'Run the sync' in flat and 'right' in flat
# ---------------------------------------------------------------------------
# Authoring: revisions, dedup and publishing. Needs an app context.
# ---------------------------------------------------------------------------
@pytest.fixture(scope='module')
def gd_app():
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
application = create_app('testing')
from plugins.guides.plugin import GuidesPlugin
plugin = GuidesPlugin()
pm = application.extensions['plugin_manager']
if plugin.meta.api_prefix not in pm._registered_prefixes:
pm._register_plugin_components(plugin)
with application.app_context():
_db.create_all()
yield application
_db.session.remove()
_db.drop_all()
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
@pytest.fixture
def gd_clean(gd_app):
from plugins.guides.models import Guide, GuideRevision
with gd_app.app_context():
_db.session.query(GuideRevision).delete()
_db.session.query(Guide).delete()
_db.session.commit()
yield
def _newguide(title='Replacing a marker controller'):
from plugins.guides.models import Guide
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
status='draft')
_db.session.add(guide)
_db.session.flush()
return guide
def test_the_first_save_creates_version_one(gd_app, gd_clean):
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
guide, revision = authoring.save(
guide, title='Replacing a marker controller',
blocks=[_block('b1', 'paragraph', text='Power it off first.')])
_db.session.commit()
assert revision is not None
assert revision.revision == 1
assert guide.revision == 1
def test_saving_identical_content_writes_no_revision(gd_app, gd_clean):
"""Autosave would otherwise produce a hundred versions a day.
A history full of identical entries hides the three edits that mattered,
which is the same failure the backups plugin dedups away.
"""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
blocks = [_block('b1', 'paragraph', text='Unchanged.')]
authoring.save(guide, title='T', blocks=blocks)
_db.session.commit()
guide, second = authoring.save(guide, title='T', blocks=blocks)
_db.session.commit()
assert second is None, 'an unchanged save must not manufacture a version'
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
assert guide.revision == 1
def test_a_real_edit_creates_the_next_version(gd_app, gd_clean):
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='One.')])
_db.session.commit()
guide, second = authoring.save(
guide, blocks=[_block('b1', 'paragraph', text='Two.')])
_db.session.commit()
assert second.revision == 2
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 2
def test_readers_see_nothing_until_it_is_published(gd_app, gd_clean):
"""A half-written procedure on a shop floor is worse than no procedure."""
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T', blocks=[_block()])
_db.session.commit()
assert authoring.readable(guide) is None
def test_editing_a_published_guide_does_not_change_what_readers_see(gd_app, gd_clean):
"""THE point of the draft/publish split.
A technician halfway through a procedure keeps reading the version that was
published, even while the next one is being written.
"""
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='Published text.')])
authoring.publish(guide)
_db.session.commit()
authoring.save(guide,
blocks=[_block('b1', 'paragraph', text='Work in progress.')])
_db.session.commit()
served = authoring.readable(guide)
assert served.blocks[0]['text'] == 'Published text.'
assert guide.revision == 2, 'the working copy moved on'
assert served.revision == 1, 'what readers get did not'
def test_publishing_promotes_the_latest_saved_revision(gd_app, gd_clean):
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='v1')])
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='v2')])
authoring.publish(guide)
_db.session.commit()
assert authoring.readable(guide).blocks[0]['text'] == 'v2'
assert guide.status == 'published'
def test_publishing_an_empty_guide_is_refused(gd_app, gd_clean):
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
_db.session.commit()
with pytest.raises(ValueError):
authoring.publish(guide)
def test_unpublishing_withdraws_without_destroying_the_history(gd_app, gd_clean):
"""For a procedure that turns out to be wrong.
The versions stay, so it can be corrected and published again, and so there
is still a record of what people were being told in the meantime.
"""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T', blocks=[_block()])
authoring.publish(guide)
_db.session.commit()
authoring.unpublish(guide)
_db.session.commit()
assert authoring.readable(guide) is None
assert guide.status == 'draft'
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
def test_restoring_writes_a_new_version_rather_than_rewriting_history(gd_app, gd_clean):
"""A history you can edit is not a record of anything.
Restoring brings old content forward as a NEW version, so what was
published in between stays visible.
"""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
_, first = authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='original')])
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='mistake')])
_db.session.commit()
authoring.restore(guide, first)
_db.session.commit()
rows = (GuideRevision.query.filter_by(guideid=guide.guideid)
.order_by(GuideRevision.revision).all())
assert [r.revision for r in rows] == [1, 2, 3]
assert rows[2].blocks[0]['text'] == 'original'
assert 'Restored from version 1' in (rows[2].note or '')
assert rows[1].blocks[0]['text'] == 'mistake', 'the mistake stays on record'
def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, gd_clean):
"""A guide that will not render is a page a person cannot read.
An empty guide with its title and tags intact is a better failure than an
error page, and the corruption is still visible - the editor shows nothing.
"""
with gd_app.app_context():
guide = _newguide()
guide.blocksjson = 'not json at all'
_db.session.commit()
assert guide.blocks == []
# ---------------------------------------------------------------------------
# The API surface. Everything above this line tests services directly; the
# draft-invisibility rule lives in the ROUTES, and until these existed no test
# executed a single one of them. That is how three routes shipped serving
# unpublished drafts to any reader who could count upwards through ids.
# ---------------------------------------------------------------------------
def _user_with(db, username, permissions, rolename=None):
"""A user holding exactly these guides permissions and nothing else."""
from werkzeug.security import generate_password_hash
from shopdb.core.models import User, Role, Permission
role = Role(rolename=rolename or (username + 'role'), description=username)
for name in permissions:
# Shared across fixtures: two users in one test both want guides.view,
# and permissions.name is unique.
permission = Permission.query.filter_by(name=name).first()
if permission is None:
permission = Permission(name=name, description=name, category='guides')
db.session.add(permission)
db.session.flush()
role.permissions.append(permission)
db.session.add(role)
db.session.flush()
user = User(username=username, email=username + '@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
db.session.add(user)
db.session.commit()
return user
def _login(client, username):
response = client.post('/api/auth/login',
json={'username': username, 'password': 'testpass'})
assert response.status_code == 200, response.get_json()
return {'Authorization':
'Bearer ' + response.get_json()['data']['access_token']}
@pytest.fixture
def reader(client, db):
"""Can read guides. Cannot author them."""
_user_with(db, 'guidereader', ['guides.view'])
return _login(client, 'guidereader')
@pytest.fixture
def author(client, db):
"""Can write their own guides, but holds no guides.edit over others'."""
_user_with(db, 'guideauthor', ['guides.view', 'guides.create'])
return _login(client, 'guideauthor')
def _draft(db, title='Unreleased procedure', published=False):
"""A guide with one saved revision, published or not."""
from plugins.guides.models import Guide
from plugins.guides.services import authoring
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
status='draft')
db.session.add(guide)
db.session.flush()
guide, revision = authoring.save(
guide, title=title,
blocks=[_block('b1', 'paragraph', text='secret draft wording')])
if published:
authoring.publish(guide)
db.session.commit()
return guide, revision
def test_a_reader_cannot_open_an_unpublished_guide_by_slug(client, db, reader):
guide, _ = _draft(db)
response = client.get('/api/guides/' + guide.slug, headers=reader)
assert response.status_code == 404
def test_a_reader_cannot_read_an_unpublished_revision_by_id(client, db, reader):
"""Revision ids are sequential, so this route is walkable by hand.
Gating it on guides.view alone meant anyone who could read one guide could
read every draft on the site by counting upwards.
"""
guide, revision = _draft(db)
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
headers=reader)
assert response.status_code == 404
assert 'secret draft wording' not in response.get_data(as_text=True)
def test_a_reader_may_read_the_published_revision_by_id(client, db, reader):
guide, revision = _draft(db, title='Published procedure', published=True)
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
headers=reader)
assert response.status_code == 200
def test_a_reader_cannot_list_a_guides_revision_history(client, db, reader):
guide, _ = _draft(db, title='Published procedure', published=True)
response = client.get('/api/guides/{}/revisions'.format(guide.guideid),
headers=reader)
assert response.status_code == 403
def test_search_does_not_match_a_reader_against_draft_wording(client, db, reader):
"""A hit a reader cannot open is worse than no hit.
The guide row holds the WORKING copy, so matching it would surface a
published guide on a word that exists only in somebody's unpublished edit -
and the word is not on the page when the reader opens it.
"""
from plugins.guides.services import authoring
guide, revision = _draft(db, title='Published procedure', published=True)
authoring.save(guide, blocks=[_block('b1', 'paragraph',
text='unpublished sentence about widgets')])
db.session.commit()
response = client.get('/api/guides?q=widgets', headers=reader)
assert response.status_code == 200
assert response.get_json()['data'] == []
# The author of the draft still finds their own work.
response = client.get('/api/guides?q=secret', headers=reader)
assert response.status_code == 200
def test_an_author_may_edit_their_own_draft_but_not_someone_elses(client, db, author):
guide, _ = _draft(db)
response = client.get('/api/guides/{}/working'.format(guide.guideid),
headers=author)
assert response.status_code == 403, 'not their guide'
def test_an_image_upload_refuses_a_type_that_is_not_an_image(client, db, author):
import io
data = {'file': (io.BytesIO(b'MZ not a picture'), 'payload.exe')}
response = client.post('/api/guides/images', data=data, headers=author,
content_type='multipart/form-data')
assert response.status_code == 400
assert 'Unsupported image type' in response.get_json()['data']['error']['message']
def test_an_image_in_a_draft_is_not_served_to_a_reader(client, db, reader, author):
"""An image is content, and a draft's images are draft content."""
import io
from plugins.guides.models import GuideImage
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
upload = client.post(
'/api/guides/images',
data={'file': (io.BytesIO(png), 'screenshot.png')},
headers=author, content_type='multipart/form-data')
assert upload.status_code == 200, upload.get_json()
imageid = upload.get_json()['data']['guideimageid']
# Unattached: it belongs to whoever uploaded it, not to every reader.
assert client.get('/api/guides/images/{}'.format(imageid),
headers=reader).status_code == 404
assert client.get('/api/guides/images/{}'.format(imageid),
headers=author).status_code == 200
# Attached to an unpublished guide: still not the reader's business.
guide, _ = _draft(db)
db.session.get(GuideImage, imageid).guideid = guide.guideid
db.session.commit()
assert client.get('/api/guides/images/{}'.format(imageid),
headers=reader).status_code == 404
def test_an_image_in_a_published_guide_is_served_and_hardened(client, db, reader, author):
"""The other half: a published screenshot must actually reach the reader.
Guarding this route without checking the published case is how you trade a
leak for every image on the site rendering as a broken icon.
"""
import io
from plugins.guides.models import Guide, GuideImage
from plugins.guides.services import authoring
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
upload = client.post(
'/api/guides/images',
data={'file': (io.BytesIO(png), 'screenshot.png')},
headers=author, content_type='multipart/form-data')
imageid = upload.get_json()['data']['guideimageid']
guide = Guide(title='With a picture', slug='with-a-picture', status='draft')
db.session.add(guide)
db.session.flush()
guide, revision = authoring.save(
guide, title='With a picture',
blocks=[{'id': 'b1', 'type': 'image', 'imageid': imageid,
'url': '/api/guides/images/{}'.format(imageid)}])
authoring.publish(guide)
db.session.get(GuideImage, imageid).guideid = guide.guideid
db.session.commit()
response = client.get('/api/guides/images/{}'.format(imageid), headers=reader)
assert response.status_code == 200
assert response.data == png
# Uploaded bytes are attacker-influenced; they must be inert in this origin.
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
assert 'Content-Security-Policy' in response.headers
def test_renaming_a_guide_does_not_move_its_url(client, db, author):
"""The stored slug exists so a pasted link keeps working.
The editor autosaves on a timer, so re-slugging on title change moved the
URL while somebody was still typing the title.
"""
created = client.post('/api/guides',
json={'title': 'Replacing a marker controler',
'blocks': []}, headers=author)
assert created.status_code == 200, created.get_json()
guideid = created.get_json()['data']['guideid']
slug = created.get_json()['data']['slug']
fixed = client.put('/api/guides/{}'.format(guideid),
json={'title': 'Replacing a marker controller',
'blocks': []}, headers=author)
assert fixed.status_code == 200
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'