Repository navigation
Modernize Attachment Delete popup attachmentDelete
The standalone Attachment Delete popup — lib/attachments/attachmentdelete.php
(+ gui/templates/dashio/attachments/attachmentdelete.tpl) — is modernized as a
standalone Dashio screen backed by a REST BFF. It deletes a single file
attachment from the node it hangs on, and is opened from the Attachments
Upload/Download popup (and from every legacy attachment list) via
deleteAttachment_onClick().
docs/MODERNIZATION-STATUS.md listed Attachments as partially modernized:
Attachments Upload/Download was done in #1525, but its Delete action
still pointed at the legacy page. That page was not a dialog at all — it
deleted the attachment while rendering it: a plain GET .../attachmentdelete.php?id=N
unlinked the file, deleted the row and wrote the audit event before the browser
ever saw any UI. Any link, bookmark or crawler hit destroyed data, and the
"do you really want to delete?" question only ever existed as a browser confirm
on the previous page. The modern screen turns the destructive action into an
explicit, session-guarded POST.
-
Screen:
gui/templates/attachments/attachmentDelete.html— Dashio popup: teal header ("Attachment Delete") with a close button, a context card (object label + owner label + attachment file name / type / size / added date), a Download link to the stored file, the red "this action is irreversible" warning, a Refresh button, the TLi18n locale switcher and the Cancel / Delete button pair. Dedicated state boxes for deleted (success), not found, not allowed, nothing selected, attachments disabled, CSRF blocked and session expired. -
BFF:
api/attachmentsdelete/index.php—GET ?action=init&id=N[&table=T][&fk_id=M]returns the attachment detail plus the owner label;POST ?action=delete {id, table, fk_id}deletes the row, unlinks the file and writesaudit_attachment_deleted(typeattachments, activityDELETE). Session auth viabffEnforceSession, POST-only for the destructive action,bffSameOriginGuardCSRF protection, thecfg->attachments_enabledgate, positive-integer id validation, owner proof from theattach_tableName/attach_fk_idpair (falling back to the legacy$_SESSION['s_lastAttachmentInfos']allow-list) → 401 anon / 403 disabled-or-not-allowed / 400 bad id / 404 unknown / 405 wrong verb. -
Link switch:
deleteAttachment_onClick()ingui/javascript/testlink_library.jsnow opensgui/templates/attachments/attachmentDelete.html?id=&table=&fk_id=(the legacy caller signature already passes the owning table and fk id, they were simply dropped before). The legacyjsCallDeleteFile()/dlfiledelete-on-GET path is no longer reachable from the UI — the four legacy attachment lists (inc_attachments.tpl/attachments.inc.tpl, Dashio + tl-classic) all call the modern function. -
Shim:
lib/attachments/attachmentdelete.phpis kept as a non-mutating session-guarded 302 redirect onto the modern screen (anon → login) — a legacy deep link now opens the confirmation screen instead of silently deleting the file. -
Legacy templates: the owner context is now forwarded by every attachment
list —
gui/templates/dashio/include/inc_attachments.tpl,gui/templates/tl-classic/inc_attachments.tpland the per-screenattachments.inc.tplwrappers passattach_tableName/attach_fk_id(for requirement versionsreq_versions+version_id), so the BFF can prove that the attachment really belongs to the object the user is looking at. -
i18n:
adel.*(29 keys) +footers.attachmentDeletein all 10 bundles (en/ro/de/es/fr/it/ja/pt/ru/zh), validated withpython3 -m json.tool.
- Browser (admin session): open from the Attachments popup of a test case →
context card + file detail render; Delete → POST → success state "The
attachment was deleted.";
?id=999999→ not-found state; a Romanian session renders every label in Romanian; a user without the right gets the not-allowed state; noid→ "nothing selected";cfg->attachments_enabled = 0→ disabled state; expired session → redirect tologin.php?note=expired; the Attachments Upload/Download list itself is unchanged. - BFF curl contract: 401 anonymous GET and POST, 405 GET on the delete
action, 400 missing/zero/negative id, 404 unknown id, 403 CSRF (POST without
X-Requested-With), 403 wrong owner, 200 init, 200 delete. - Legacy shim: anonymous → login redirect; authenticated → 302 to the modern screen; no row and no file are removed (the destructive GET is gone).
- Event Viewer clean after the run (only audit/login rows, no Error/Warning);
console clean;
php -lclean. - Harness
tmp/verify_1638.sh(108 assertions, all green) also asserts statically: the delete action is POST-only, the BFF fails closed withNOT_AUTHENTICATEDbefore any query,bffEnforceSessionis present, the legacy shim contains nodeleteAttachment/attachmentRepositorycall, the library forwards&table=and&fk_id=, all four legacy templates forward the owner context, bothreqViewVersions.tplvariants passattach_tableName="req_versions", both session lists append instead of clobbering,attach_idis escaped in all four link templates, theattachments->attachmentDeleteaction is registered, all 29adel.*keys are identical in the 10 bundles and every key the screen uses is defined, and no newlog_level IN (1,2)rows appeared in the Event Viewer.
-
#1639 — the BFF had no session-auth check. A crafted anonymous
POST /api/attachmentsdelete/?action=delete&id=Ndeleted an attachment. Fixed withbffEnforceSession(401NOT_AUTHENTICATED) before any query. -
#1640 — the owner context was lost.
attachments.inc.tplbuilt the link withoutattach_tableName/attach_fk_id, and the per-version loops inlib/functions/testcase.class.phpandlib/requirements/reqView.phpoverwrote$_SESSION['s_lastAttachmentInfos']on every iteration, so the session allow-list only ever described the last row. Fixed in the templates (reqViewVersions.tplnow passesattach_tableName="req_versions"+ the version id) and by appending (getAttachmentInfosFrom(..., true, 1)) instead of clobbering. -
Flat-vs-list result mismatch —
getAttachmentInfo()returns a single flat row, the code dereferenced$info[0], so every delete was refused with "not allowed". -
Slim-schema crash — the owner-label enrichment assumed columns
(
tc_external,buildsname) that a reduced TestLink database does not have; the column probe now degrades gracefully. -
Untranslated button — the Delete button read the raw key
adel.deleteBtn(no such key existed). -
Wrong object type — node type
4is a test version, but was labelled "Build". - Spurious 500 — when the file was already missing on disk the row was deleted correctly but the unlink failure was treated as a hard error; a missing file is now tolerated (the row is the source of truth).
Fixes landed in e5c402ad7, 9dcf71d9c, c6bb0d8f9 and the mandatory code
review commit 665128bf2.
#1647 — object-level authorization. The delete is protected by
authentication, CSRF, the feature gate and the owner-context proof, but the
BFF does not yet check mgt_modify_key on the owning test project. A logged-in
user who guesses a valid (id, table, fk_id) triple can still delete an
attachment from a project they have no rights on. Filed with repro steps and a
suggested fix; it is deliberately tracked separately from this screen.
Suite 1638 (Screen — Attachment Delete popup) in tmp/TLU_Test_Cases.md —
108/108 PASS, plus 10 browser cases executed manually (list, delete, not found,
Romanian, not allowed, no id, disabled, session expired, legacy shim, upload
list regression).
Screenshots:




Fixture: tmp/fixtures_1638.php (a test project with a test case, a
requirement spec, a test plan and an execution, each with an uploaded
attachment; ids are dynamic and never hardcoded).
-
api/attachmentsdelete/index.php— delete BFF (init + delete) -
gui/templates/attachments/attachmentDelete.html— standalone Dashio popup -
lib/attachments/attachmentdelete.php— session-guarded, non-mutating 302 shim -
gui/javascript/testlink_library.js—deleteAttachment_onClick() -
gui/templates/dashio/include/inc_attachments.tpl,gui/templates/tl-classic/inc_attachments.tpl— owner-context wrappers -
gui/templates/dashio/include/attachments.inc.tpl,gui/templates/tl-classic/attachments.inc.tpl— per-screen owner context -
gui/templates/dashio/requirements/reqViewVersions.tpl,gui/templates/tl-classic/requirements/reqViewVersions.tpl—req_versionsowner context -
lib/functions/testcase.class.php,lib/requirements/reqView.php— session allow-list is appended, not clobbered -
gui/templates/i18n/*.json— localizedadel.*keys (10 bundles) -
docs/screenshots/issue-1638-attachmentDelete-*.png— browser evidence
Refs #1638.
2026-10-01
A rights-less authenticated user could delete any attachment of any object by
POSTing to api/attachmentsdelete/index.php?action=delete or
api/attachments/index.php?action=delete. The endpoints proved ownership
((table,fk_id) match + session allow-list) but never asked authorization
on the owning object ($currentUser was never used for a rights decision).
Fix: shared gate api/_attachauth.php derives the owning test project from
the stored row for every supported fk_table (nodes_hierarchy, testprojects,
testplans, builds, executions, execution_tcsteps,
req_specs/requirement_specs, requirements, req_versions, keywords,
cfields/custom_fields, tcversions) and requires the caller to hold a
visibility right on it via tlUser::hasRight(). The check runs after the
ownership proof and uses row values (never caller-supplied). NO_RIGHT → 403;
the UI maps that to the existing "Not allowed" state. Admins unaffected; the
private-project rule (#1763) is enforced; the ?action=init metadata oracle
is also closed. ?action=list/upload/download remain ungated (the download path is the
public share-link route #1541); they were measured during the run and are tracked
in #1768.
Code review outcome (mandatory review): two blockers found and fixed —
(1) the "no derivable project" fallback was fail-open: hasRight($db,$right,0)
skips the private-project guard (tlUser.class.php:886), so a globally privileged
account (built-in roles 4/6/7/9) could still delete attachments of a private
project it has no role on — now an owner whose project cannot be derived is denied,
except the two project-less owners (node type 13 platform / 14 user);
(2) req_versions resolved through latest_req_version, a GROUP BY max() view
that only knows the newest version of each requirement, so attachments of older
versions (the reqView version selector can attach to those) fell into that same
fail-open path — now resolved through the nodes_hierarchy parent walk. Also
closed: the rights gate now runs before the ownership proof so
ATTACHMENT_NOT_ALLOWED vs NO_RIGHT can no longer enumerate which object owns a
guessed attachment id; the untranslated English message was dropped from the
api/attachments 403 so reqSpecView/reqView fall back to their localized key;
the unused attAuthHasColumn() was deleted.
Verification: 10/10 PASS (rights-less account denied on both endpoints and both owner kinds, admin unchanged, globally privileged user kept on public project and denied on private). Event Viewer: no Error/Warning.

Refs #1647.