| Age | Commit message (Collapse) | Author |
|
|
|
|
|
Any holder may add or remove another account, witnessed as
redaktion_grant/revoke so the vouching is legible. Not behind elevation:
onboarding must not wait for a keyholder, and a compromised Redaktion
account can already publish.
|
|
Administrative actions are gated behind a 30-minute elevation window:
creating and retiring accounts, editing roles, clearing a second factor.
Reading the list is not gated, and content work is untouched.
elevated? is tied to is_admin?, so losing the role closes the window at
once. The window opens when the second factor verifies at login, so an
admin heading straight for user management is already elevated, and closes
on logout with the other session state. Five wrong codes end the session,
mirroring the login challenge.
users#update carries no elevation filter, since self-service reaches it;
the role field is gated in user_params instead and fails closed.
|
|
RoleRequired supplies require_redaktion and require_admin for surfaces that
are not nodes and so cannot be reached by Node#restricted?.
Navigation is content rather than plumbing, so menu_items requires
redaktion. User management is janitorial and requires admin: index, new,
create, reset_otp, deactivate, reactivate. verify_status now also covers
show, without which any logged-in user could read any account by walking a
small id space. Editing your own account stays open.
The dashboard hides the Users and Navigation buttons from those who cannot
use them; everything else stays visible to everyone. Both denials share one
message and land on the dashboard.
Adds redella (redaktion) and alufa (redaktion + alumni) fixtures.
|
|
CccConventions::TAG_SCOPES maps "update" and "pressemitteilung" to /updates
and "disclosure" to /disclosure. Page.aggregate applies the mapping, so an
aggregate over one of those tags is bounded by its subtree regardless of what
the shortcode says. Tags stay unrestricted; positions are publish-gated.
Both RSS actions call Page.aggregate rather than repeating its tag join.
Empty feeds no longer crash: Atom falls back to the current time for the
required <updated>, and the optional dc:date is omitted.
|
|
publish_draft!, trash!, destroy_from_trash!, attach_asset! and
Asset#destroy_witnessed! now refuse unless the acting user holds redaktion,
and only when the subject is on a restricted surface: the front page, the
updates tree that feeds ~100k subscribers, or disclosure. Drafting,
autosaving, tagging and creating stay free everywhere for everyone.
Enforcement is in the models rather than the controllers, since attach_asset!
and the rest are reachable from rake tasks and internal paths. It follows the
errors.add-plus-bare-raise pattern the rest of Node already uses, so every
existing RecordInvalid rescue reports it with a localised message; only
assets_controller#destroy needed a rescue added.
A nil user is treated as a system context and bypasses the gate. The default
nil on three of those verbs is what makes that reachable, and removing those
defaults once every call site passes a user is the next tightening.
|
|
Deactivation adds the alumni role and leaves the others in place, so
reactivation is lossless and nobody has to remember what an account held.
login_from_session checks alumni? on every request, so a signed-in user is
locked out on their next one without any session invalidation. Guards
prevent deactivating yourself or the last active admin, and both verbs are
witnessed in the action log.
|
|
Replaces the two-way admin/user split with four groups ordered by
capability: administration, Redaktion, editors, alumni. alumni takes
precedence over capability in role_group, so a retired admin appears at the
bottom rather than the top.
Forms now offer the three roles as checkboxes rather than a single admin
checkbox, with a trailing hidden blank so an empty set can be posted, and
user_params permits roles only for admins. Three create buttons prefill the
common combinations.
|
|
|
|
German content is reachable at both /updates/foo and /de/updates/foo,
and a page with no English translation renders German at
/en/updates/foo through the fallback chain, so three URLs each
claimed to be canonical. Both tags now derive from one
locale-to-URL function, which points every duplicate at the address of
the version actually served, the unprefixed German URL, matching
default_url_options.
Alternates are declared only for locales in which the page genuinely has
a translation, since /en/ serving German through the fallback is not an
English version of the page. x-default points at German, the site's
primary language.
|
|
Replaces one hardcoded German description and an unrenderable SVG with
per-page title, description, canonical URL, locale and publication date,
plus the card variant or a site-wide default.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Enforced at restore via a login-time stamp, written only at genuine
logins so the limit stays absolute rather than sliding. The cookie
name rotation logs everyone out once at deploy. Second-factor users
are deliberately not treated worse than password-only ones.
|
|
Enrolled users get a pending marker instead of a session after the
password step; a valid code through the challenge writes the real
session via reset_session. otp_required without enrollment funnels
into setup everywhere except the enrollment, user, and login
machinery.
|
|
|
|
Asset names in summaries and publish deltas link to assets#show with
an inline Chronik beside each, suppressed inside that asset's own
zoom, per the node convention. assets#show gains a history button.
Also renames the details summary (no longer only translations) and
moves View Diff onto its own line.
|
|
ensure_autosave! gives body keystrokes and asset curation one shared
layer, so head is never mutated in place and every curation change
surfaces in the publish delta. Stale rendered join ids are mapped
across the clone via asset_id. Curation now requires holding the
lock; a missing lock answers 423, matching the autosave endpoint.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pages_controller#preview loaded a page by id and rendered it
directly, assuming @page.node was always present. True for head and
draft, since both carry a real node_id: false for autosave, which
deliberately has node_id: nil (the mechanism that excludes it from
Node#pages and the revision count). A node with an autosave and no
draft underneath it: lock, start editing, never explicitly save a
draft: loads that autosave directly with no node at all, and any
view code assuming @page.node is real (headline image, credits,
gallery links) raises.
When @page has no node, resolve it by checking autosave_id, then
draft_id, then head_id on Node, and set @page.node in memory only:
the autosave's node_id: nil is never persisted differently. The
existing swap from draft to a fresher autosave, when both exist, is
unchanged.
Three tests: draft with a fresher autosave on top, autosave with no
draft underneath, and the ordinary head-only case with neither.
|
|
- related_assets gains a `headline` boolean (DB-enforced: at most one
per page), replacing "first image by position" as the headline
rule. A rake task backfills the current first image on every live
head/draft, so nothing changes visually until an editor changes it.
- The image picker sidebar gets a star toggle reflecting the flag;
the TinyMCE inline-image picker's badge now reads it too, instead
of assuming position 0.
- No headline chosen (or none attached) now falls back to the
gallery-count caption itself becoming the lightbox trigger, instead
of the gallery being unreachable.
- Assets gain creator, source_url, and license_key (against a new
config/asset_licenses.yml dictionary). asset_credit renders a
degrading attribution line, reused as a hidden per-image glightbox
caption so credit is one click away for every image, not only the
headline's always-visible one.
- Fixed: asset thumbnails rendered unconditionally regardless of
whether a real variant exists on disk. Asset#has_variant? checks
file existence, not content type -- some legacy PDFs have real
pre-rewrite thumbnails a content-type check would have hidden.
- assets#new/edit rebuilt onto the same node_description/node_content
layout as assets#show, picking up the three new fields in the
process.
|
|
|
|
- dark-mode restore now travels nonced, the admin constants likewise
- AUTH_TOKEN deleted in favour of the csrf meta tag
- new report collector at /csp_reports
|
|
Each row in nodes#show's History section now carries terse badges
from the action log: created, published, and restored (rollback
re-promotion), with date and actor, rendered from entry metadata
alone. Backfilled entries wear the inferred marker, so
reconstructed provenance stays distinguishable from witnessed
history. A revision that was published and later restored shows
both badges chronologically -- its true biography.
Only create and publish entries carry page_id; trash, restore, and
destroy annotate the node's own log zoom instead of any single
revision, by design.
|
|
nodes#show gains a Trash section on trashed nodes: provenance from
the trash entry, a restore form whose parent picker pre-fills the
old parent while it still lives, and permanent deletion. A
Move-to-Trash button joins the status actions on living nodes.
nodes#trashed lists trashed subtree roots with weight, provenance,
and deletion; the dashboard housekeeping row links to it, and
trash/destroy redirect there.
Deletion from Trash now removes the whole subtree, deepest first,
each node through a real destroy! so every per-node cascade runs --
amending the never-recursive rule for this one sanctioned path
(both confirms state the count; the root entry carries
destroyed_descendants). Bare Node#destroy still refuses children.
|
|
|
|
Page.aggregate interpolated order_by into SQL unchecked while
already allowlisting order_direction; the column is now normalized
and checked against the sortable columns, falling back to pages.id.
Its values arrive from editor-authored aggregate shortcodes, so
this was editor-gated, but the asymmetry was wrong regardless.
template_name and default_template_name now validate inclusion in
Page.custom_templates -- names render as filesystem paths, so only
names actually present in the template directory are acceptable.
Validated only on change: legacy rows whose template file has since
vanished stay saveable, and valid_template's render-time fallback
to standard_template continues to cover them. Two tests that wrote
fabricated template names through the front door now arrange their
state correctly (update_column for the stale-name fallback test, a
real template for the update-persists test).
|
|
NodeActionsController#index lists entries newest-first, filterable
by node_id or user_id -- the two zoom shapes the log was designed
around. Rendering goes through NodeActionsHelper.action_summary,
which builds one sentence per entry from metadata alone, so entries
referencing deleted users or nodes render from their snapshots;
live associations only upgrade names to links. Unknown verbs
degrade to a generic sentence rather than an error, since the log
outlives its vocabulary. The helper is the escaping boundary:
every metadata value passes through h() before assembly.
Actor names link to the log's own user zoom rather than the unused
users page -- inspecting a suspicious user's other actions is the
intended workflow. Publish entries with a translation_diff expose
a collapsed per-locale change table linking out to the revision
itself. Sentences live in en.yml/de.yml following the existing
widget-string convention. nodes#show links to its node's zoomed
log.
|
|
A contract comment above NodeAction.record! now specifies every
verb's metadata shape. NodeAction.head_diff computes the publish
diff between an outgoing head and its replacement -- default-locale
title pair always, author/tags pairs and template/assets/abstract/
body flags only when changed, and a per-locale translation_diff
with added/removed/changed status. It is a pure function of its two
pages, shared by publish, rollback, and the future backfill, and
reads translation rows directly so fallbacks never masquerade as
content.
publish entries carry via ("draft" or "revision"); restore_revision!
is now transactional, takes the acting user, and logs through the
same diff. Staged slug/parent changes applied at publish log a move
entry with the path pair. Node creation logs a create entry with
initial title and path. The draft-scoped translation_destroy writer
is retired -- locale removal is recorded by the publish diff, where
it becomes public fact.
|
|
node_id/page_id/user_id are lookup and ordering only -- all three
nullify on delete, so an entry outlives its actor and its subject.
Everything that must survive those deletions lives in a mandatory
metadata jsonb written once at creation: the actor's username, the
node's human-readable name (pinned to the default locale), and
action-specific extras such as publish's title from/to.
NodeAction.record! is the single constructor, so every entry gets
the same baseline metadata without each call site re-implementing
it. occurred_at is one field for live and backfilled entries alike;
inferred_from distinguishes them -- nil means witnessed at the
moment it happened, populated names how a backfilled entry was
estimated.
Instrumented so far: publish (crediting the actual publisher,
threaded through from the controller -- previously nobody had the
act of publishing recorded anywhere), revert's discard_autosave and
destroy_draft branches, and translation destroy. publish_draft! now
runs in a transaction so the promotion and its log entry land
together. The remaining verbs follow once this mechanism has proven
itself.
|
|
Sized generously (1600x1600>) rather than against today's cramped
490px content column specifically -- shrinking an oversized source
to fit a narrow container is free and looks fine; a source too
small for a future, wider redesign has no fix short of re-uploading
every image. Purely additive: nothing reads :large yet, and
generate_variants already loops over the whole STYLES hash
generically, so no other code needed to change for new uploads to
start getting it. Test suite's own hardcoded style lists updated in
the same commit so they don't quietly drift out of sync with what
the app actually defines.
|
|
Both had already lost their reason to exist as production API:
wipe_draft!'s one remaining callsite (nodes#show) was removed two
sessions ago, and find_or_create_draft had zero production callers
left at all -- confirmed by a fresh grep, not assumed -- every one of
its ~65 call sites was test setup, unrelated to what those tests
actually cover.
wipe_draft! is deleted outright, along with its two tests -- the
lock/draft/autosave cleanup it silently performed already has
explicit, always-visible manual equivalents (Unlock, Discard
Autosave, Destroy Draft), so nothing real is lost.
find_or_create_draft moves to test_helper.rb as a plain method on
ActiveSupport::TestCase, alongside the create_node_with_draft/
create_node_with_published_page helpers already living there --
extending the framework's own designated test-extension point
rather than reopening the Node model from test code. Its three
tests of real dispatch behavior (idempotency on repeat calls, and
raising when a second user contends for the lock) are kept, since
~65 other tests depend on this helper actually working correctly;
only the call syntax changed, from node.find_or_create_draft(user)
to find_or_create_draft(node, user).
|
|
|
|
diff_against compared title/abstract/body under whatever I18n.locale
happened to be ambient, with no concept of 'diff this translation
specifically' -- so a change confined to one locale was invisible to
Diff Head vs. Draft regardless of which locale you were looking at
when you clicked it, exactly the 'yields nothing' complaint from
earlier this session.
Page#diff_against gains a locale: keyword, additive only -- nil
preserves the exact original ambient-locale behavior every existing
caller and test already depends on; passing a locale switches to
reading each side's actual PageTranslation row directly, same
fallback-free reasoning as Page#translation_summary. Page#locale_diff_summary
reports one entry per locale present on either side, so an added or
removed translation counts as a change even where content matches
everywhere it exists on both.
RevisionsController#diff now resolves a real locale before diffing --
defaulting to whichever locale actually changed, falling back to the
default locale only when nothing did -- and the view carries that
locale through every existing control (view toggle, layer-pair
buttons, the revision-select form) so it and the view/layer-pair axis
stay independently selectable rather than resetting each other.
|