VPR-59: Migrate CMS from VIPER 1 to VIPER 2#242
Conversation
- New /api/cms/files endpoints: paged list with filters, get, folders, multipart upload, edit with permission/person deltas, encrypt toggle, file replacement, soft delete/restore, admin permanent delete, and a queryable fileAudit log matching legacy audit actions - CmsFileCrypto implements ColdFusion-compatible AES-128-ECB with UU-encoded per-file keys so both systems can read each other's encrypted files during coexistence; CMS.cs decrypt now delegates to it - CmsFileStorageService validates folders against the storage root (containment + top-level whitelist), uploads via temp-then-move, and reproduces legacy _0.._999 collision renaming - Encrypt/decrypt-in-place writes are atomic (temp file + move) to avoid corrupting files on interrupted writes
- Files page at /CMS/ManageFiles (not /CMS/Files, which is the MVC download handler): server-paged list with folder/status/search/ encrypted filters, upload and edit dialogs, soft delete/restore - Shared PermissionSelector and PersonSelector backed by new /api/cms/options endpoints, since the RAPS permission list requires RAPS roles + 2FA and Directory search is too heavy for type-ahead - File audit log page with action/user/date/search filters and per-file filtering via ?fileGuid= - CMS home cards shown per user permission - ViperFetch gains postForm/putForm for multipart uploads
- Extend /api/CMS/content: status/system/section/search filters, get-by-id, version history list and retrieval, restore, admin-gated permanent delete, content-only PATCH, file attach/detach, and ModifiedOn-based conflict detection (409 on stale saves) - Fix history semantics to match legacy: store the previous version with its original author/date (was writing the new content, and create wrote a history row with block id 0) - List endpoint projects without the Content column to avoid loading every block body; attached file GUIDs are validated on save - UI: content block list page and editor (QEditor, settings sidebar, permissions, file attachment via search, version history with restore-by-save, conflict dialog)
- New /api/cms/left-navs (ManageNavigation): menu CRUD with cascade delete, and a batch item save that adds/updates/deletes/reorders in one call, replacing the legacy DataTables editor flow - Fix nav display filter: items with no permissions are now visible to any logged-in user (legacy behavior); previously they were hidden from rendered menus on Layout/Home/Students pages - Validate left nav item URLs with SafeUrlAttribute - UI: menu list page and item editor with drag/arrow reordering and per-item permission selectors; shared EditButton/ModifiedStamp components extracted from list pages
- POST /api/cms/files/import moves files from the legacy VIPER webroot (CMS:LegacyWebrootPath) into the managed store with per-file results; the original path is stored as oldURL so existing links keep working, matching legacy move-and-track semantics; source paths are contained to the webroot and removed only after a fully successful import - POST /api/cms/files/bulk-encrypt encrypts unencrypted files in place with per-file results; a failed key save rolls the file back to plaintext so disk and DB never disagree, and batch failures clear the change tracker so one bad file cannot poison the rest - UI: Import page (paths textarea keeps failures for retry) and Bulk Encrypt page (multi-select over unencrypted files); list pages now surface fetch errors instead of showing silent empty tables
- /api/cms/photos serves user photos to any authenticated user by
mailId, loginId, or iamId (legacy userPhoto.cfc parity), resolving
ids through AAUD
- altPhoto=true serves the alternate ProfilePhotos/{iamId}.jpg when
present; invalid or traversal-shaped ids fall back to the default
photo without touching disk outside the photo roots
- ID-card photo lookup, caching, and the nopic fallback reuse the
Students area PhotoService rather than duplicating the pipeline
- ZIP requests get a strict per-user token bucket (archives are assembled in memory); single files get a generous sliding window - Buckets key on CAS login, falling back to the proxy-resolved client IP for anonymous requests; rejections return 429 with Retry-After - Limits are configurable under CMS:DownloadRateLimit; invalid values fall back to defaults instead of failing downloads
- Left nav now mirrors legacy CMS (Home, content blocks, files, left-nav sections, permission-gated); nav, hub cards, and page titles share "Manage X" wording, and /CMS/ canonicalizes to /CMS/Home so the Home link highlights - Hub: permission-filtered 2x2 tool cards plus a recent-activity rail (recently edited blocks/files deep-link to their editors via existing list APIs); zero-permission visitors get an info banner instead of a blank page - Import and Bulk Encrypt moved off the hub into a File Tools menu on Manage Files; the Upload File nav link opens the upload dialog via ?upload=1 - Shell a11y (all areas): visible focus ring on flat card actions, left nav items announce as links, drawer landmark labeled, new "Skip to section menu" link, and in-page anchor navigation no longer steals focus or scroll position - Shared router: scroll position survives query-only navigation, hash links scroll to their target; createSpaRouter.ts renamed to create-spa-router.ts (kebab convention) with new unit tests
- Workspace anchors now use the brand link color and dense table action buttons keep a 24px minimum hit area (base.css, applies app-wide) - Icon-only status and result indicators get screen-reader text; links that open new windows announce it - Loading buttons use the design-system spinner+label slot; deleted banner on content block edit uses StatusBanner; sidebar/card headings are real h2s - Create buttons match nav wording (Add Content Block, Add Left-Nav Menu, Add Collection); removed the duplicate Audit Log button on Manage Files - Hand-rolled "file(s)" plurals replaced with inflect(); inline styles extracted to classes
- File URLs now point at the VIPER2 download handler (/CMS/Files, permission-checked, serves public files anonymously) instead of the ColdFusion /cms/files path; host keeps non-default ports and the URL respects PathBase on TEST/PROD - Manage Files: Old URL column collapses to a compact link (full URL in tooltip) that resolves through the download handler; filters sync to the query string for shareable deep links; new "Public only" toggle (isPublic was already supported by the API) - Folder filter lists disk folders unioned with folders that exist only on file records (e.g. VMDEVSignatures in dev), with an explicit "All" default; upload destinations keep the disk-only allow-list since the first path segment is a security check - Hub cards render as equal-height tiles via a CSS grid with 1fr rows
Matches the legacy UI and exercises the real resolution chain old links take (VIPER 1 404 handler -> oldURL lookup) instead of calling the CMS handler lookup directly.
- Replace the MakeUnique flag with a check-name endpoint plus explicit FileName/Overwrite request fields, so an upload detects a name conflict up front and prompts to rename or overwrite - Add an import dry-run preview that reports the resulting names and any auto-renames without moving files, sharing path validation with the real import so it can't promise what import would reject - Reconcile the on-disk file back to its pre-save state when a metadata save fails across create, update, import, and bulk-encrypt - Require a replacement upload to keep the original file's extension so a stored .pdf can't end up holding .png bytes - Add per-folder file counts for the filter dropdowns, and extract shared BreadcrumbHeading and StatusIcon components
- Surface validation and save errors in an inline banner beside the submit button on the content block, left-nav, file, and link forms, replacing transient toasts that scrolled out of view. - Guard unsaved edits on those forms: route-leave prompts on the pages and confirm-on-close on the dialogs, via useUnsavedChanges. - Make Add Left-Nav Menu a settings-only modal opened from the list (and the nav via ?add=1) that routes to the page for item editing; a shared LeftNavMenuSettingsFields backs both the modal and page. - Auto-enable Public access when a content block's System is set to "Public", with an inline notice, since AllowPublicAccess is the only field that actually gates unauthenticated visibility. - Add a "Public only" filter to the content block list, backed by a new isPublic parameter on the list endpoint and service. - Replace "Back to ..." buttons with breadcrumb headings and align the page titles and entry labels to "Add" (including "Add File"). - Default the System filter to an explicit "All" on the menu and block lists.
- List pages (Files, Content Blocks, Left Nav, Audit, Bulk Encrypt) collapse to stacked cards below the mobile breakpoint instead of scrolling wide tables - a11y: give the rich-text editor an accessible name, mark the System select required, and make the Link card keyboard-reachable via its anchor - Align the file and Effort audit-trail filter layouts (outlined controls; search and Clear Filters on their own row); this accounts for the Effort/AuditList change in a CMS commit - Replace remaining hard-coded colors with brand tokens and move inline dialog widths to shared classes
- Cross-block edit history page (linked from the CMS home) with per-version diffs: diff a saved version against its predecessor, or diff the editor's current draft against any past version. Diffs use htmldiff.net and are re-sanitized server-side so only ins/del markup reaches the browser. - Restrict the public content-block lookup to active blocks so soft-deleted content is no longer served to unauthenticated callers.
- Frontend: Vitest/@vue/test-utils suites for CMS utils, components, dialogs, selectors, list pages, and the content-history and diff views - Backend: controller tests for files, content, left-nav, link collections, and user photos, plus content-block permission filtering - Add SanitizeDiff cases proving the diff sanitizer strips XSS while keeping the ins/del change markers
PostLinkCollection, CreateLinkCollectionTagCategory, and PostLink passed a local variable name to CreatedAtAction instead of an action method, so the 201 Location header URL generation would fail at runtime through routing. Point each at its existing collection GET action; these resources have no get-by-id endpoint to target.
…tions - Extract a shared, accessible SortableList (drag + up/down, "just moved" highlight, live-region announcements, reduced-motion fallback) to replace the duplicated per-page drag handlers - Track left-nav menu settings and items as independently dirty/saved sections; rename "Revert" to "Discard Item Changes" - Add a per-row Delete to link-collection links
- Restore endpoints return 204 instead of 200 with an empty body, so the client no longer reports a false "failed to restore" (files and blocks) - Deleting a link collection now removes its links, link tags, and tag categories first, fixing a foreign-key 500 - Creating a tag category returns the generated id and validates the id and route/body, so the follow-up order save no longer 400s or 500s - Content-block list filters initialize from and sync to the URL, matching the Files list (deep-linkable views)
- Snapshot a file's original bytes before an overwrite or replacement and restore them if the database save fails, so a failed create or edit can't orphan the new bytes or destroy the original. - If the restore itself fails, keep the backup and log its path for recovery instead of deleting the only remaining copy. - Import preview reserves each planned name so sources sharing a base name preview the unique names the import will actually assign.
- Treats "//host" as off-site and blocks it; backslash variants
("/\", "\\", "\/") normalize to the same bypass and are rejected
too. Ordinary relative paths still validate.
- The /CMS/Home route and the /CMS/ root redirect both accept any granular CMS permission (Files, Content Blocks, Left Nav, ...), not just base SVMSecure.CMS, reading one shared set so the guard and the canonicalization can't drift.
- Adopt the shared StatusBadge over raw q-badge and an ad-hoc palette, and replace hard-coded px/hex with rem and brand tokens. - Improve a11y (SR-hidden status icons, grouped/labelled left-nav rows) and keep button labels visible while loading. - ContentBlock watches only its name prop (drops a duplicate fetch), renders null-safe, and shares heading styles via the global sheet. - ContentBlockEdit skips the section-path/file APIs for create-only users so they don't fire requests that can only 403; the left-nav list now surfaces load errors.
- Add an inline uploader to the content-block editor: files stage client-side and only upload on Save, using the block's folder and permissions, with per-file conflict resolution (rename/overwrite/reuse) and rollback of newly created files if the save then fails - Make the block's VIPER section path required on create and read-only after, sourced from a new content-scoped /content/folders endpoint so create-only users can pick a folder without AllFiles - Restore legacy 30-day trash retention: a daily CmsTrashPurgeScheduledJob permanently deletes files (record + disk) past the cutoff, detaching content-block links first and tolerating per-file disk failures. Gated by a Cms:TrashPurgeEnabled flag (default off) so it ships disabled until the legacy VIPER 1 purge is retired and survives app restarts - Scope the trash to its owner for non-admins (restore and list match on ModifiedBy, failing closed) and open a Trash entry on the hub; surface the purge date on deleted files - Drop the System and Order columns from the content-blocks list; polish responsive list headers, stacked permission chips, and link a11y
Bundle ReportChanges will increase total bundle size by 122.19kB (5.75%) ⬆️
Affected Assets, Files, and Routes:view changes for bundle: viper-frontend-esmAssets Changed:
Files in
Files in
Files in
Files in
Files in
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #242 +/- ##
==========================================
+ Coverage 44.58% 48.21% +3.63%
==========================================
Files 896 969 +73
Lines 51733 56298 +4565
Branches 4834 5509 +675
==========================================
+ Hits 23063 27145 +4082
- Misses 28098 28372 +274
- Partials 572 781 +209
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
- Treat both '/' and '\' as separators for legacy import lines and user-supplied file/folder names; traversal and invalid-name checks no longer rely on host-OS path APIs (Linux CI let "..\" and "<>" through) - Build test path expectations with Path.Join instead of hardcoded '\' - Clear ReSharper/CodeQL findings in the file services: specific catch filter on the bulk-encrypt save, disposed test streams, redundant qualifiers and suppressions
- Drop always-true null checks and dead coalesces flagged by NRT contracts; flatten EF search conditions CodeQL called too complex - [JsonRequired] on LeftNavItemEdit ints so under-posted JSON is a 400 instead of silently creating items or clearing headers - Test hygiene: dispose form-file streams, remove redundant suppressions and qualifiers, pass TestContext cancellation token
- New useServerTable/useUrlFilteredTable composables own the shared QTable pagination + URL-synced filter plumbing, removing the clone groups jscpd flagged vs main (delta now +0) - Split high-complexity save/upload functions into small helpers; fallow suppressions remain only for template-size synthetics
- GET content/fn/{friendlyName} projected raw ContentBlock entities:
attached files exposed their AES key and server path, plus full
unsanitized ContentHistory and permission rows, to anonymous callers
- Return ContentBlockDto (same consumer fields); regression test asserts
the payload carries no key/filePath/contentHistories
- X-Content-Type-Options: nosniff on all /CMS/Files responses - html/htm/xhtml/svg force Content-Disposition: attachment (keyed off the stored file path, not the user-supplied fn) so an uploaded page cannot render in the app origin; html stays on the allow-list for legacy files
- Align DTO MaxLengths to DB columns (overlong input was a 500 at SaveChanges); validate each permission string via MaxLengthEach - Reject unknown item ids on batch save (409) instead of silently re-creating concurrently deleted items; duplicate ids 400 from the service; friendly-name uniqueness enforced case-insensitively
- DateRangeHelper no longer overflows on DateTime.MaxValue.Date filters - CmsNavMenu takes IUserHelper via constructor like LeftNavMenu - EF.Parameter on the attached-file GUID Contains check - Document the legacy-parity CATS.Admin gate (verified in CF source)
- 75 new Vitest specs: content-block save/conflict flows, inline upload commit paths, file dialog conflicts, table composables, CmsHome personas, list page actions - Worst-covered files now 76-100% lines; patch coverage ~60% -> ~85%
- Mirrors the files list: ApiPagination envelope, whitelisted sort columns, count-before-page; list no longer loads every block client- side and the rows-per-page All option is gone - ContentBlocks.vue now uses useServerTable like its sibling lists; card-mode breakpoint standardized to lt.sm
- Link-collection edit dialog now guards unsaved tag edits on close, matching the link dialog and the DESIGN.md close-guard pattern - Filter bars unified to dense outlined across the five list pages - Tag colors moved off semantic status roles to a categorical palette; diff tints and focus ring tokenized; content-block headings get a descending scale; px to rem; deep-watcher and stale ?upload=1 query-sync race fixed
- loadBlocks sorted a default (alphabetical, 50-row) page client-side, so recent edits past the first page never appeared; request sortBy=modifiedOn perPage=5 server-side like loadFiles, with a spec pinning the params - Drop the bespoke field-error pill on the link form, residual px in InlineFileUpload, and the text-h6 that weakened the rail heading
- Hub boxes now match the left nav's three groups in its order, with Link Collections inside Content Blocks and the nav's Add File / Add Left-Nav Menu deep-links as card actions - Warn file managers when trashed files purge within 7 days (new deletedOn sort on the files list backs it); recently deleted files join the activity rail - Rail items link to their edit history / audit trail and open the latest change as an inline diff (live content vs newest saved version via the editor's POST diff contract); shared useContentDiffViewer replaces three parallel viewer implementations - Row actions fade in on hover/focus, stay visible on touch
- The rail's row markup (actions cluster, verb caption, hover reveal) pushed RecentActivity's template past the complexity threshold; the row is now its own component and the shared item types live in CMS/types
|
@coderabbitai full review |
✅ Action performedFull review finished. |
|
Superseded by a 6-PR stack so each slice fits under GitHub Copilot's 20,000-line review limit: #245 (files) -> #246 (content blocks + left nav) -> #247 (import/photos/rate limiting) -> #248 (hub/a11y/UX) -> #249 (responsive/diffs/tests) -> #250 (hardening + review fixes, final slice). Note this PR's branch holds the stale pre-rebase history (the local branch was rebased onto main on 2026-06-23 and not force-pushed); the stack branches carry the current, test-verified history. Closing in favor of the stack. |
Summary
Ports the legacy VIPER 1 CMS to VIPER 2 as a new API + Vue SPA: file
management, content blocks, left-nav menus, and link collections, plus the
supporting trash lifecycle and scheduler wiring.
What's included
from the legacy VIPER webroot, VIPER 2 download links, ZIP download, audit
trail, download rate limiting, user photo endpoints).
upload that stages files client-side and only uploads on Save, with per-file
conflict resolution (rename / overwrite / reuse) and rollback on a failed save.
files they deleted; admins see the whole trash) and a 30-day auto-purge
scheduled job that restores legacy retention.
and access for granular-permission users.
across CMS pages.
Trash retention / auto-purge (deployment-sensitive)
This branch adds
CmsTrashPurgeScheduledJob(recurring-job idcms:trash-purge,cron
0 3 * * *— daily at 3:00 AM Pacific). When enabled it permanentlydeletes CMS files that were soft-deleted more than 30 days ago (DB record +
bytes on disk), matching the legacy VIPER 1 30-day purge routine.
The legacy VIPER 1 scheduled task performs the same 30-day purge. To keep both
from purging in parallel, the job is gated by a config flag and ships
disabled: it stays registered and fires on cron, but no-ops until the flag is
turned on. It is only enabled after the legacy job is retired.
Cms:TrashPurgeEnabled(config / SSM). Absent orfalse= disabled.falseinappsettings.json, so every environment is off untilit explicitly opts in. Unlike a Hangfire dashboard tweak, this survives app
restarts and IIS app-pool recycles.
Deployment steps
Development→ TEST; thenmain→ PROD). No actionneeded to keep the purge off — it ships disabled (
Cms:TrashPurgeEnabledisfalseby default). The legacy VIPER 1 task stays the sole purge./scheduler/dashboard(requires the
SVMSecure.CATS.scheduledJobsRAPS permission) that thecms:trash-purgeruns log "disabled … skipping" (Hangfire.Console output).Cutover (once the legacy job is turned off)
Cms:TrashPurgeEnabled = true(via SSM in deployed envs) andrestart/redeploy so the app picks up the new config value.
cms:trash-purgeonce and check the consoleoutput confirms a real run and the purged-file count. From then on the daily
3:00 AM run is the sole purge.
To roll back to the legacy purge at any time, set the flag back to
falseandrestart — no code change or dashboard surgery required.
Testing
npm run test(backend + frontend), lint, and build verification pass(enforced by the pre-commit hook).